mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
Compare commits
81 Commits
v0.9.1
..
b4d1a4d8d4
| Author | SHA1 | Date | |
|---|---|---|---|
| b4d1a4d8d4 | |||
| 8216c91739 | |||
| de96a5d3e7 | |||
| 79ca1730cd | |||
| ad7679b7fd | |||
| 0215b2787e | |||
| 75dfeced01 | |||
| bfdf453e3c | |||
| 55cc6a9c61 | |||
| 3ab3659fe4 | |||
| 04c2b9b0b6 | |||
| 9ac9894dba | |||
| 9b397bd45e | |||
| 225cae2c24 | |||
| 08d409d5eb | |||
| 54b83f8b16 | |||
| 3879853836 | |||
| 2824687d9f | |||
| 3e85bf77c2 | |||
| cd44c7c818 | |||
| c69b0d6c0c | |||
| 13c81ee560 | |||
| c38f728e10 | |||
| b70995f8e9 | |||
| fca75a99ea | |||
| 8bf2adad89 | |||
| 0d97237c71 | |||
| c65b07cfe7 | |||
| 7fcb7b38fe | |||
| 2a00a0332b | |||
| 8a48611625 | |||
| dec1b97b9e | |||
| 449d22a41b | |||
| 4b4701f5b9 | |||
| f4ef71b359 | |||
| c19a7e5f2c | |||
| 09642d4c2b | |||
| 9f9b258d59 | |||
| 85b81e661c | |||
| 049a15cb95 | |||
| 2a36a10e7f | |||
| 864eb5969c | |||
| b0093c48c7 | |||
| 4a69b7c863 | |||
| 52b171b49b | |||
| 4dc1ba88cd | |||
| 7a905ba307 | |||
| 07d1d8345f | |||
| 8d2ddbd045 | |||
| 1c805ed061 | |||
| 84972ea0f0 | |||
| 7c9e3e123e | |||
| 3e0be9bb20 | |||
| a8a3b8697f | |||
| 2d546e3478 | |||
| 1a35cab919 | |||
| ff157c33d9 | |||
| 51ad344b36 | |||
| 3623a543bb | |||
| 377176a0f2 | |||
| a4022df7b4 | |||
| 32aa1b66f5 | |||
| 53e66bad35 | |||
| ff68279b0f | |||
| 9c533a7ff8 | |||
| 785a162f88 | |||
| 06933451c5 | |||
| d17d212c24 | |||
| 51a2accd9f | |||
| 96ea7481bc | |||
| de9c45a944 | |||
| 9e885d8b2f | |||
| 74c3a3a08c | |||
| 0a19854602 | |||
| 67263da0f1 | |||
| 95879e33d4 | |||
| 0c059e8a28 | |||
| 485059f60e | |||
| 143bc84ac3 | |||
| 06856e5caa | |||
| b9bde0f95b |
@@ -1,15 +1,12 @@
|
|||||||
name: docker-alpine-epusdt
|
name: docker-alpine-epusdt
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- "main"
|
|
||||||
- "master"
|
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
DOCKERHUB_NAMESPACE: gmwallet
|
||||||
IMAGE_NAME: epusdt
|
IMAGE_NAME: epusdt
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -29,12 +26,11 @@ jobs:
|
|||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: ${{ secrets.DOCKERHUB_USERNAME }}/${{ env.IMAGE_NAME }}
|
images: ${{ env.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=alpine
|
type=raw,value=alpine
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest
|
||||||
type=ref,event=branch,suffix=-alpine
|
type=ref,event=tag
|
||||||
type=ref,event=tag,suffix=-alpine
|
|
||||||
type=sha,prefix=sha-,suffix=-alpine
|
type=sha,prefix=sha-,suffix=-alpine
|
||||||
|
|
||||||
- name: Login To Docker Hub
|
- name: Login To Docker Hub
|
||||||
@@ -52,5 +48,6 @@ jobs:
|
|||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
build-args: |
|
build-args: |
|
||||||
API_RATE_URL=${{ vars.API_RATE_URL }}
|
API_RATE_URL=${{ vars.API_RATE_URL }}
|
||||||
|
BUILD_VERSION=${{ github.ref_name }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
name: sync-www
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
paths-ignore:
|
||||||
|
- "src/www/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sync-www-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-sync:
|
||||||
|
name: Build Frontend And Sync src/www
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
FRONTEND_REPOSITORY: ${{ vars.WWW_FRONTEND_REPOSITORY }}
|
||||||
|
FRONTEND_REF: ${{ vars.WWW_FRONTEND_REF }}
|
||||||
|
FRONTEND_BUILD_DIR: ${{ vars.WWW_FRONTEND_BUILD_DIR }}
|
||||||
|
FRONTEND_TOKEN: ${{ secrets.WWW_FRONTEND_TOKEN }}
|
||||||
|
WWW_TARGET_DIR: src/www
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Backend
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.ref_name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Validate Configuration
|
||||||
|
run: |
|
||||||
|
if [[ -z "$FRONTEND_REPOSITORY" ]]; then
|
||||||
|
echo "Set repository variable WWW_FRONTEND_REPOSITORY, for example owner/repo"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$FRONTEND_REF" ]]; then
|
||||||
|
echo "Set repository variable WWW_FRONTEND_REF, for example main"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$FRONTEND_BUILD_DIR" ]]; then
|
||||||
|
echo "Set repository variable WWW_FRONTEND_BUILD_DIR, for example dist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$FRONTEND_TOKEN" ]]; then
|
||||||
|
echo "Set repository secret WWW_FRONTEND_TOKEN to read the private frontend repository"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Clone Frontend
|
||||||
|
run: |
|
||||||
|
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
||||||
|
frontend_path="$RUNNER_TEMP/$frontend_name"
|
||||||
|
git clone \
|
||||||
|
--depth=1 \
|
||||||
|
--branch "$FRONTEND_REF" \
|
||||||
|
"https://x-access-token:${FRONTEND_TOKEN}@github.com/${FRONTEND_REPOSITORY}.git" \
|
||||||
|
"$frontend_path"
|
||||||
|
|
||||||
|
- name: Set Up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
run: |
|
||||||
|
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
||||||
|
cd "$RUNNER_TEMP/$frontend_name"
|
||||||
|
bun install
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
- name: Replace src/www
|
||||||
|
run: |
|
||||||
|
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
||||||
|
cd "$RUNNER_TEMP/$frontend_name"
|
||||||
|
if [[ ! -d "$FRONTEND_BUILD_DIR" ]]; then
|
||||||
|
echo "Frontend build output does not exist: $FRONTEND_BUILD_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
target_path="$GITHUB_WORKSPACE/$WWW_TARGET_DIR"
|
||||||
|
mkdir -p "$target_path"
|
||||||
|
rsync -a --delete "$FRONTEND_BUILD_DIR"/ "$target_path"/
|
||||||
|
|
||||||
|
- name: Commit And Push
|
||||||
|
run: |
|
||||||
|
if [[ -z "$(git status --porcelain -- "$WWW_TARGET_DIR")" ]]; then
|
||||||
|
echo "src/www is already up to date"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add "$WWW_TARGET_DIR"
|
||||||
|
git commit -m "chore(www): sync frontend build"
|
||||||
|
git pull --rebase origin "$GITHUB_REF_NAME"
|
||||||
|
git push origin "HEAD:$GITHUB_REF_NAME"
|
||||||
+2
-3
@@ -8,8 +8,9 @@ WORKDIR /app
|
|||||||
COPY . /app
|
COPY . /app
|
||||||
|
|
||||||
WORKDIR /app/src
|
WORKDIR /app/src
|
||||||
|
ARG BUILD_VERSION=0.0.0-dev
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
RUN go build -o /app/epusdt .
|
RUN go build -trimpath -ldflags="-s -w -X github.com/GMWalletApp/epusdt/config.BuildVersion=${BUILD_VERSION}" -o /app/epusdt .
|
||||||
|
|
||||||
FROM alpine:latest AS runner
|
FROM alpine:latest AS runner
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
@@ -17,8 +18,6 @@ RUN apk --no-cache add ca-certificates tzdata
|
|||||||
ARG API_RATE_URL=""
|
ARG API_RATE_URL=""
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=builder /app/src/static /app/static
|
|
||||||
COPY --from=builder /app/src/static /static
|
|
||||||
COPY --from=builder /app/src/.env.example /app/.env
|
COPY --from=builder /app/src/.env.example /app/.env
|
||||||
RUN if [ -n "$API_RATE_URL" ]; then \
|
RUN if [ -n "$API_RATE_URL" ]; then \
|
||||||
sed -i "s|^api_rate_url=.*$|api_rate_url=${API_RATE_URL}|" /app/.env; \
|
sed -i "s|^api_rate_url=.*$|api_rate_url=${API_RATE_URL}|" /app/.env; \
|
||||||
|
|||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
# Epusdt — Easy Payment USDT
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="https://gmwallet.app/favicon.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<strong>Open-source multi-chain crypto payment gateway · Top real-world adoption</strong>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="./README.en.md">English</a> |
|
||||||
|
<a href="./README.zh-CN.md">简体中文</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/Docs-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
|
||||||
|
<a href="https://t.me/epusdt"><img src="https://img.shields.io/badge/Telegram-Channel-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"></a>
|
||||||
|
<a href="https://t.me/epusdt_group"><img src="https://img.shields.io/badge/Telegram-Group-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Group"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/GMWalletApp/epusdt/stargazers"><img src="https://img.shields.io/github/stars/GMWalletApp/epusdt?style=flat-square&color=f5c542" alt="GitHub Stars 3000+"></a>
|
||||||
|
<a href="https://www.gnu.org/licenses/gpl-3.0.html"><img src="https://img.shields.io/badge/License-GPLv3-blue?style=flat-square" alt="GPLv3 License"></a>
|
||||||
|
<a href="https://golang.org"><img src="https://img.shields.io/badge/Go-1.16+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go 1.16+"></a>
|
||||||
|
<a href="https://github.com/GMWalletApp/epusdt/releases"><img src="https://img.shields.io/github/v/release/GMWalletApp/epusdt?style=flat-square&color=green" alt="Latest Release"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is Epusdt?
|
||||||
|
|
||||||
|
**Epusdt** (Easy Payment USDT) is a self-hosted **multi-chain, multi-token crypto payment gateway** built with Go. It started as a TRC20-only solution and evolved into a broader **multi-chain receiving platform**, allowing websites and applications to accept crypto payments across multiple networks and token types. No third-party custody, no platform fees — funds go directly into your wallet.
|
||||||
|
|
||||||
|
> **GitHub Star 3000+** · **10+ supported platform integrations** · **Top real-world adoption among crypto payment tools**
|
||||||
|
|
||||||
|
Deploy it privately, integrate with the HTTP API, and start receiving crypto payments in minutes.
|
||||||
|
|
||||||
|
### Supported Networks and Tokens
|
||||||
|
|
||||||
|
| Network | Tokens |
|
||||||
|
|---------|--------|
|
||||||
|
| **TRC20** (Tron) | USDT, TRX |
|
||||||
|
| **ERC20** (Ethereum) | USDT, USDC, ETH |
|
||||||
|
| **Solana** | USDT, USDC |
|
||||||
|
| **BEP20** (BSC) | USDT, USDC, BNB |
|
||||||
|
| **Polygon** | USDT, USDC |
|
||||||
|
| **Aptos** | USDC, USDT |
|
||||||
|
| **More** | Expanding continuously |
|
||||||
|
|
||||||
|
> Actual support depends on the [latest release](https://github.com/GMWalletApp/epusdt/releases) and the [official docs](https://epusdt.com).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Broad Compatibility, Plug and Play
|
||||||
|
|
||||||
|
No matter what kind of platform you operate, Epusdt can fit into existing payment integration patterns with **minimal or no business logic refactor**, giving you a fast path to crypto payments for global use cases:
|
||||||
|
|
||||||
|
| Category | Supported Systems |
|
||||||
|
|----------|-------------------|
|
||||||
|
| **AI Distribution** | [OneAPI](https://github.com/songquanpeng/one-api), [NewAPI](https://github.com/QuantumNous/new-api) |
|
||||||
|
| **Card/Voucher Platforms** | [Dujiaoka](https://dujiao-next.com/), [ACG Faka](https://github.com/lizhipay/acg-faka) |
|
||||||
|
| **Proxy Panels** | [V2Board](https://github.com/v2board/v2board), [XBoard](https://github.com/cedar2025/Xboard), [xiaoV2board](https://github.com/wyx2685/v2board/), [SSPanel](https://github.com/anankke/sspanel-uim) |
|
||||||
|
| **Website Ecosystem** | [WordPress](https://wordpress.com/), [WHMCS](https://www.whmcs.com/) |
|
||||||
|
| **Epay-Compatible Flow** | Works with platforms already using Epay-style payment interfaces |
|
||||||
|
| **More** | Simple HTTP API, often integrated within 10 minutes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Features
|
||||||
|
|
||||||
|
- **Multi-chain and multi-token** — TRC20, ERC20, BEP20, Polygon, Aptos, and more
|
||||||
|
- **Self-hosted** — full control of funds and wallet infrastructure
|
||||||
|
- **Low-dependency runtime** — a single binary can run the service; low-traffic setups do not require MySQL + Redis
|
||||||
|
- **Cross-platform** — supports x86 / ARM on Windows / Linux / Mac
|
||||||
|
- **Multi-wallet rotation** — rotates receiving addresses to improve concurrency handling
|
||||||
|
- **Async callback queue** — handles merchant notifications efficiently
|
||||||
|
- **HTTP API** — easy integration from any language or framework
|
||||||
|
- **Telegram Bot** — instant payment notifications and lightweight operations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Documentation and Guides
|
||||||
|
|
||||||
|
Full documentation: **[epusdt.com](https://epusdt.com)**
|
||||||
|
|
||||||
|
Quick-start links:
|
||||||
|
|
||||||
|
| Guide | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| [Docker Deployment](https://epusdt.com/guide/installation/docker) | Recommended one-command setup |
|
||||||
|
| [aaPanel Deployment](https://epusdt.com/guide/installation/aapanel) | Great for aaPanel users |
|
||||||
|
| [Manual Deployment](https://epusdt.com/guide/installation/manual.html) | Full manual control |
|
||||||
|
| [Developer API Docs](https://epusdt.com) | Integration reference |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```text
|
||||||
|
Epusdt
|
||||||
|
├── src/ Core project source code
|
||||||
|
└── wiki/ Documentation assets and knowledge base
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
Epusdt listens to supported blockchain APIs or RPC nodes across multiple networks such as TRC20, ERC20, BEP20, and Polygon. It captures incoming token transfers in real time and matches ownership using **amount differentiation** and **time-bounded locks**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Workflow:
|
||||||
|
1. A customer initiates payment and needs to send 20.05 USDT
|
||||||
|
2. The system searches for an available wallet-address + amount combination
|
||||||
|
3. If address_1:20.05 is free -> lock it for 10 minutes and return it
|
||||||
|
4. If already occupied -> add 0.0001 and try the next amount combination
|
||||||
|
5. Background listeners keep watching wallet deposits and mark the order paid once the amount matches
|
||||||
|
```
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Community and Support
|
||||||
|
|
||||||
|
If you hit an issue, please open a [GitHub Issue](https://github.com/GMWalletApp/epusdt/issues) first. That is the best way to get attention and help.
|
||||||
|
|
||||||
|
| Channel | Link |
|
||||||
|
|---------|------|
|
||||||
|
| Telegram Channel | [https://t.me/epusdt](https://t.me/epusdt) |
|
||||||
|
| Telegram Group | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
|
||||||
|
| Official Docs | [https://epusdt.com](https://epusdt.com) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Star History
|
||||||
|
|
||||||
|
<a href="https://www.star-history.com/?type=date&repos=gmwalletapp%2Fepusdt">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&theme=dark&legend=top-left" />
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&legend=top-left" />
|
||||||
|
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=gmwalletapp/epusdt&type=date&legend=top-left" />
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Epusdt is released under the [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) license.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Disclaimer and Terms of Use
|
||||||
|
|
||||||
|
EPusdt is developed and published by Good Morning Technology, LLC as free, open-source, non-profit, and non-custodial software for learning, research, and technical communication. Nothing in this project constitutes investment, financial, legal, tax, compliance, or any other professional advice, nor should it be interpreted as a guarantee regarding any asset, transaction outcome, revenue, fund safety, technical availability, or fitness for a particular purpose.
|
||||||
|
|
||||||
|
Good Morning Technology, LLC is an entity organized under United States law and will comply with applicable legal and regulatory obligations as required. Under U.S. regulatory frameworks, whether virtual currencies, digital assets, or related technical services constitute money transmission, a money services business (MSB), or another regulated activity generally depends on the specific business model, including whether the operator receives, holds, controls, or transmits funds or value, provides custody on behalf of users, or engages in exchange, payment, clearing, settlement, or other intermediary financial services.
|
||||||
|
|
||||||
|
As a free, open-source, non-profit, and non-custodial software project, the source code, documentation, technical communication, and related development activities of EPusdt should not be interpreted as authorization, endorsement, control, participation, warranty, or commitment by Good Morning Technology, LLC or its contributors regarding any user's downstream use, modification, deployment, integration, distribution, or secondary development.
|
||||||
|
|
||||||
|
Users independently determine how they use this project, for what purpose, and in what context. Good Morning Technology, LLC and its contributors cannot control, review, or restrict such conduct. Users are solely responsible for ensuring that their use, modification, deployment, integration, distribution, or derivative development complies with applicable laws, regulations, sanctions rules, and third-party rights in the relevant jurisdictions, and they bear all resulting risks, liabilities, and consequences.
|
||||||
|
|
||||||
|
Crypto assets are a high-risk emerging asset class. Stablecoins and other digital assets may experience sharp volatility, de-pegging, insufficient liquidity, technical failures, regulatory changes, or total loss of value. All code, documentation, and related materials in this project are provided on an "as is" and "as available" basis. Except where mandatory law provides otherwise, Good Morning Technology, LLC and its contributors are not liable for any direct or indirect loss resulting from use, inability to use, misuse, unlawful use, modification, deployment, integration, distribution, derivative development, or reliance on this project.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<sub>
|
||||||
|
<b>Keywords:</b> USDT Payment Gateway · Crypto Payment · Multi-chain Payment · TRC20 Payment · ERC20 Payment · BEP20 Payment ·
|
||||||
|
Self-hosted Crypto Gateway · OneAPI Payment · NewAPI Payment · Dujiaoka Payment · ACG Faka Payment ·
|
||||||
|
V2Board Payment · XBoard Payment · SSPanel Payment Interface ·
|
||||||
|
WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT ·
|
||||||
|
Epusdt · Easy Payment USDT · Open Source Payment Gateway · Multi-chain Collection
|
||||||
|
</sub>
|
||||||
|
</p>
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
# Epusdt — Easy Payment USDT
|
# Epusdt — Easy Payment USDT
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="wiki/img/usdtlogo.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
|
<img src="https://gmwallet.app/favicon.png" alt="Epusdt Logo - Multi-chain Crypto Payment Gateway" width="120">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
|
<strong>开源多链多币种 Crypto 支付网关 · 实际采用率 Top 1</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="./README.en.md">English</a> |
|
||||||
|
<a href="./README.md">简体中文</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/官网文档-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
|
<a href="https://epusdt.com"><img src="https://img.shields.io/badge/官网文档-epusdt.com-blue?style=for-the-badge" alt="Official Docs"></a>
|
||||||
<a href="https://t.me/epusdt"><img src="https://img.shields.io/badge/Telegram-频道-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"></a>
|
<a href="https://t.me/epusdt"><img src="https://img.shields.io/badge/Telegram-频道-26A5E4?style=for-the-badge&logo=telegram&logoColor=white" alt="Telegram Channel"></a>
|
||||||
@@ -23,62 +28,67 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🌍 What is Epusdt?
|
## What is Epusdt?
|
||||||
|
|
||||||
**Epusdt** (Easy Payment USDT) is a self-hosted **multi-chain, multi-token crypto payment gateway** built with Go. It has evolved from a TRC20-only solution into a comprehensive **multi-chain receiving platform**, enabling any website or application to accept crypto payments across multiple blockchain networks and token types. No third-party fees, no custodial risk — funds go directly into your wallet.
|
**Epusdt** (Easy Payment USDT) 是一个基于 Go 构建、支持私有化部署的 **多链多币种 Crypto 支付网关**。它从最初的 TRC20 单链方案逐步演进为完整的 **多链收款平台**,让任意网站或应用都能快速接入多条链、多种代币的加密支付能力。没有第三方托管,没有平台抽成,资金直接进入你的钱包。
|
||||||
|
|
||||||
> **⭐ GitHub Star 3000+** · **🔌 已支持站点解决方案 10+** · **🏆 Crypto 支付工具实际采用率 Top 1**
|
> **GitHub Star 3000+** · **已支持站点解决方案 10+** · **Crypto 支付工具实际采用率 Top 1**
|
||||||
|
|
||||||
Deploy it privately, integrate via HTTP API, and start receiving **crypto payments** in minutes. That's it. 🎉
|
私有部署,按 HTTP API 接入,几分钟内就可以开始接收 **Crypto Payments**。
|
||||||
|
|
||||||
### 🔗 已支持网络与代币
|
### 已支持网络与代币
|
||||||
|
|
||||||
| 网络 | 代币 |
|
| 网络 | 代币 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| **TRC20** (Tron) | USDT、TRX|
|
| **TRC20** (Tron) | USDT、TRX |
|
||||||
| **ERC20** (Ethereum) | USDT、USDC、ETH |
|
| **ERC20** (Ethereum) | USDT、USDC、ETH |
|
||||||
| **Solana** | USDT、USDC |
|
| **Solana** | USDT、USDC |
|
||||||
| **BEP20** (BSC) | USDT、USDC、BNB |
|
| **BEP20** (BSC) | USDT、USDC、BNB |
|
||||||
| **Polygon** | USDT、USDC |
|
| **Polygon** | USDT、USDC |
|
||||||
|
| **Aptos** | USDC、USDT |
|
||||||
| **更多** | 持续扩展中… |
|
| **更多** | 持续扩展中… |
|
||||||
|
|
||||||
> 💡 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
|
> 具体支持的链与代币以 [最新版本](https://github.com/GMWalletApp/epusdt/releases) 及 [官方文档](https://epusdt.com) 为准。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔌 广泛兼容,即插即用
|
## 安全审计
|
||||||
|
Epusdt 已完成第三方安全审计。
|
||||||
|
[查看安全审计报告](https://github.com/VectorBits/audit/blob/main/epusdt-secure-audit-report-2026-05-14.pdf)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 广泛兼容,即插即用
|
||||||
|
|
||||||
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
|
无论你运营的是哪类系统,Epusdt 均可基于现有接口方案,**无需重构业务逻辑**,快速接入,立即获得 Crypto 收款能力,低成本扩展全球支付场景:
|
||||||
|
|
||||||
| 领域 | 已支持系统 |
|
| 领域 | 已支持系统 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| **AI 分发** | [OneAPI](https://github.com/songquanpeng/one-api)、[NewAPI](https://github.com/QuantumNous/new-api) |
|
| **AI 分发** | [Sub2API](https://github.com/Wei-Shaw/sub2api)、[NewAPI](https://github.com/QuantumNous/new-api) |
|
||||||
| **发卡系统** | [独角数卡(Dujiaoka)](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) |
|
| **发卡系统** | [独角数卡(Dujiaoka)](https://dujiao-next.com/)、[异次元发卡](https://github.com/lizhipay/acg-faka) |
|
||||||
| **代理面板** | [V2Board](https://github.com/v2board/v2board)、[XBoard](https://github.com/cedar2025/Xboard)、[xiaoV2board](https://github.com/wyx2685/v2board/)、[SSPanel](https://github.com/anankke/sspanel-uim) |
|
| **代理面板** | [V2Board](https://github.com/v2board/v2board)、[XBoard](https://github.com/cedar2025/Xboard)、[xiaoV2board](https://github.com/wyx2685/v2board/)、[SSPanel](https://github.com/anankke/sspanel-uim) |
|
||||||
| **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) |
|
| **建站生态** | [WordPress](https://wordpress.com/)、[WHMCS](https://www.whmcs.com/) |
|
||||||
| **Epay兼容** | 兼容各类支持Epay易支付接口的平台 |
|
| **Epay 兼容** | 兼容各类支持 Epay 易支付接口的平台 |
|
||||||
| **更多** | 简易HTTP API 10分钟内接入 |
|
| **更多** | 简易 HTTP API,10 分钟内接入 |
|
||||||
|
|
||||||
👉 查看更多集成列表与插件:[plugins/](plugins/)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ 核心特性
|
## 核心特性
|
||||||
|
|
||||||
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon 等主流网络,不再局限于单一链
|
- **多链多币种** — 支持 TRC20、ERC20、BEP20、Polygon、Aptos 等主流网络
|
||||||
- **私有化部署** — 无需担心钱包被篡改、吞单,资金完全自主掌控
|
- **私有化部署** — 资金完全自主掌控
|
||||||
- **零依赖运行** — 单个二进制文件即可启动,低并发场景无需安装 MySQL + Redis,一键部署零成本维护
|
- **零依赖运行** — 单个二进制即可启动,低并发场景无需 MySQL + Redis
|
||||||
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac 设备
|
- **跨平台** — 支持 x86 / ARM 架构的 Windows / Linux / Mac
|
||||||
- **多钱包轮询** — 自动轮换收款地址,提高订单并发处理能力
|
- **多钱包轮询** — 自动轮换收款地址,提高并发处理能力
|
||||||
- **异步队列** — 高性能消息回调,优雅处理高并发场景
|
- **异步队列** — 高性能消息回调,适配高并发场景
|
||||||
- **HTTP API** — 标准化接口,任何语言 / 框架均可10min内集成
|
- **HTTP API** — 标准化接口,任何语言 / 框架都能快速集成
|
||||||
- **Telegram Bot** — 实时支付通知,快捷管理与监控
|
- **Telegram Bot** — 实时支付通知,快捷管理与监控
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📖 文档与教程
|
## 文档与教程
|
||||||
|
|
||||||
完整文档请访问 👉 **[epusdt.com](https://epusdt.com)**
|
完整文档请访问:**[epusdt.com](https://epusdt.com)**
|
||||||
|
|
||||||
快速入门:
|
快速入门:
|
||||||
|
|
||||||
@@ -91,29 +101,51 @@ Deploy it privately, integrate via HTTP API, and start receiving **crypto paymen
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🏗️ 项目结构
|
## 项目结构
|
||||||
|
|
||||||
```
|
```text
|
||||||
Epusdt
|
Epusdt
|
||||||
├── plugins/ → 已集成的系统插件(独角数卡等)
|
├── src/ 项目核心代码
|
||||||
├── src/ → 项目核心代码
|
└── wiki/ 文档与知识库
|
||||||
├── sdk/ → 接入 SDK
|
|
||||||
├── sql/ → 数据库安装 / 升级脚本
|
|
||||||
└── wiki/ → 文档与知识库
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🔧 实现原理
|
## 程序截图
|
||||||
|
|
||||||
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或RPC节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<img src="wiki/img/web2.png" alt="Epusdt 管理面板首页" height="260"><br>
|
||||||
|
<sub>管理面板首页</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<img src="wiki/img/web1.png" alt="Epusdt 管理面板" height="260"><br>
|
||||||
|
<sub>管理面板</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<img src="wiki/img/pay1.jpeg" alt="Epusdt 收银台" height="260"><br>
|
||||||
|
<sub>收银台</sub>
|
||||||
|
</td>
|
||||||
|
<td align="center" valign="top">
|
||||||
|
<img src="wiki/img/pay2.jpeg" alt="Epusdt 支付页面" height="260"><br>
|
||||||
|
<sub>支付页面</sub>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
```
|
---
|
||||||
|
|
||||||
|
## 实现原理
|
||||||
|
|
||||||
|
Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等)的 API 或 RPC 节点,实时捕获钱包地址的代币入账事件,利用**金额差异**与**时效性**精确匹配交易归属:
|
||||||
|
|
||||||
|
```text
|
||||||
工作流程:
|
工作流程:
|
||||||
1. 客户发起支付,需支付 20.05 USDT
|
1. 客户发起支付,需支付 20.05 USDT
|
||||||
2. 系统在哈希表中查找可用的 钱包地址 + 金额 组合
|
2. 系统在哈希表中查找可用的钱包地址 + 金额组合
|
||||||
3. 若 address_1:20.05 未被占用 → 锁定该组合(有效期 10 分钟),返回给客户
|
3. 若 address_1:20.05 未被占用 -> 锁定该组合(有效期 10 分钟),返回给客户
|
||||||
4. 若已被占用 → 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
|
4. 若已被占用 -> 自动累加 0.0001 尝试下一个金额组合(最多 100 次)
|
||||||
5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
|
5. 后台线程持续监听所有钱包的入账事件,金额匹配则确认支付成功
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -121,17 +153,15 @@ Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 💬 社区与支持
|
## 社区与支持
|
||||||
|
|
||||||
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/GMWalletApp/epusdt/issues),我们会**优先处理** Issue 中的反馈。
|
**遇到问题?** 请优先在 GitHub 提交 [Issue](https://github.com/GMWalletApp/epusdt/issues),我们会优先处理反馈。
|
||||||
|
|
||||||
加入 Telegram 社区,获取最新开发动态,参与需求调研,与超过5000名活跃用户交流使用经验,对接商业资源:
|
|
||||||
|
|
||||||
| 渠道 | 链接 |
|
| 渠道 | 链接 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 📢 **Epusdt 频道** | [https://t.me/epusdt](https://t.me/epusdt) |
|
| Epusdt 频道 | [https://t.me/epusdt](https://t.me/epusdt) |
|
||||||
| 💬 **Epusdt 交流群** | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
|
| Epusdt 交流群 | [https://t.me/epusdt_group](https://t.me/epusdt_group) |
|
||||||
| 📚 **官方文档站** | [https://epusdt.com](https://epusdt.com) |
|
| 官方文档站 | [https://epusdt.com](https://epusdt.com) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -147,24 +177,32 @@ Epusdt 通过监听多条区块链网络(TRC20、ERC20、BEP20、Polygon 等
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 📜 开源协议
|
## 开源协议
|
||||||
|
|
||||||
Epusdt 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议。
|
Epusdt 遵守 [GPLv3](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ⚠️ 免责声明
|
## 免责声明及使用条款
|
||||||
|
|
||||||
本项目仅供学习与技术交流使用,用户在使用过程中需自行遵守所在地法律法规。由于涉及加密资产及资金安全,用户应自行审查相关代码与风险。加密资产属于高风险新兴资产(包括稳定币),其价值可能波动甚至归零,GMwallet 不对任何资产或使用结果作出保证。本内容不构成任何投资、税务、法律或金融建议,仅供教育用途,相关决策请咨询专业人士。
|
EPusdt 由 Good Morning Technology, LLC 以免费、开源、非盈利及非托管性质开发和披露,仅供学习、研究与技术交流使用。项目本身不构成投资、金融、法律、税务、合规或任何其他专业建议,也不应被视为对任何资产、交易结果、收益、资金安全、技术可用性或特定用途作出任何保证。
|
||||||
|
|
||||||
|
Good Morning Technology, LLC 为依据美国法律设立的主体,并将在适用法律法规范围内履行相应合规义务。就美国监管框架而言,虚拟货币、数字资产及相关技术服务是否构成 money transmission、money services business(MSB)或其他受监管活动,通常取决于具体业务模式,包括项目方是否接收、持有、控制或传输资金或价值,是否代表用户托管资产,以及是否从事兑换、支付、清算、结算或其他中介性金融服务。
|
||||||
|
|
||||||
|
作为免费、开源、非盈利、非托管的软件项目,EPusdt 的代码披露、文档说明、技术交流及相关开发活动,本身不应被理解为 Good Morning Technology, LLC 或其贡献者对任何用户后续使用、修改、部署、集成、分发或二次开发行为的授权、背书、控制、参与、保证或承诺。
|
||||||
|
|
||||||
|
用户对本项目的实际使用方式、使用目的及后续行为由其自行决定,Good Morning Technology, LLC 及其贡献者无法控制、审查或限制该等行为。用户应自行确保其使用、修改、部署、集成、分发或二次开发行为符合所在地适用法律法规、监管要求、制裁规则及第三方权利,并独立承担由此产生的全部风险、责任与后果。
|
||||||
|
|
||||||
|
加密资产属于高风险新兴资产类别,包括稳定币在内的数字资产均可能发生剧烈波动、脱锚、流动性不足、技术故障、监管变化或价值归零等风险。本项目所有代码、文档及相关材料均按“现状”和“可用状态”提供。除适用法律另有强制规定外,Good Morning Technology, LLC 及其贡献者不因用户使用、无法使用、错误使用、违法使用、修改、部署、集成、分发、二次开发或依赖本项目而产生的任何直接或间接损失承担责任。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<sub>
|
<sub>
|
||||||
<b>Keywords:</b> USDT Payment Gateway · Crypto Payment · Multi-chain Payment · TRC20 Payment · ERC20 Payment · BEP20 Payment ·
|
<b>Keywords:</b> USDT Payment Gateway · Crypto Payment · Multi-chain Payment · TRC20 Payment · ERC20 Payment · BEP20 Payment ·
|
||||||
Self-hosted Crypto Gateway · OneAPI Payment · NewAPI Payment · 独角数卡支付 · 异次元发卡支付方式 ·
|
Self-hosted Crypto Gateway · OneAPI Payment · NewAPI Payment · 独角数卡支付 · 异次元发卡支付方式 ·
|
||||||
V2Board Payment · XBoard Payment · SSPanel 支付接口 ·
|
V2Board Payment · XBoard Payment · SSPanel 支付接口 ·
|
||||||
WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT ·
|
WordPress Crypto Payment · WHMCS USDT Payment · Polygon USDT ·
|
||||||
Epusdt · Easy Payment USDT · Open Source Payment Gateway · 多链收款
|
Epusdt · Easy Payment USDT · Open Source Payment Gateway · 多链收款
|
||||||
</sub>
|
</sub>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
epusdt:
|
epusdt:
|
||||||
image: gmwallet/epusdt:alpine
|
image: gmwallet/epusdt:latest
|
||||||
restart: always
|
restart: always
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
## 使用方法
|
|
||||||
### 注意此插件仅适用于独角数卡2.0.4版本以下,2.0.4版本或以上的独角数卡已经内置此插件,无需配置
|
|
||||||
|
|
||||||
1.将`app`和`routes`目录覆盖到网站根目录。
|
|
||||||
2.在独角数卡后台添加一个支付方式。
|
|
||||||
|
|
||||||
| 支付选项 | 商户id | 商户key | 商户密钥 | 备注 |
|
|
||||||
| :-----| :----- | :----- | :----- |:-----|
|
|
||||||
| Epusdt | api接口认证token | 空 | epusdt收银台地址+/api/v1/order/create-transaction| 如果独角数卡和epusdt在同一服务器则填写`127.0.0.1`不要填域名,例如`http://127.0.0.1:8000/api/v1/order/create-transaction` |
|
|
||||||
|
|
||||||
示例:
|
|
||||||

|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* The file was created by Assimon.
|
|
||||||
*
|
|
||||||
* @author GMWalletApp<ashang@utf8.hk>
|
|
||||||
* @copyright GMWalletApp<ashang@utf8.hk>
|
|
||||||
* @link http://utf8.hk/
|
|
||||||
*/
|
|
||||||
|
|
||||||
namespace App\Http\Controllers\Pay;
|
|
||||||
|
|
||||||
|
|
||||||
use App\Exceptions\RuleValidationException;
|
|
||||||
use App\Http\Controllers\PayController;
|
|
||||||
use GuzzleHttp\Client;
|
|
||||||
use GuzzleHttp\Exception\GuzzleException;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class EpusdtController extends PayController
|
|
||||||
{
|
|
||||||
public function gateway(string $payway, string $orderSN)
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
// 加载网关
|
|
||||||
$this->loadGateWay($orderSN, $payway);
|
|
||||||
//构造要请求的参数数组,无需改动
|
|
||||||
$parameter = [
|
|
||||||
"amount" => (float)$this->order->actual_price,//原价
|
|
||||||
"order_id" => $this->order->order_sn, //可以是用户ID,站内商户订单号,用户名
|
|
||||||
'redirect_url' => route('epusdt-return', ['order_id' => $this->order->order_sn]),
|
|
||||||
'notify_url' => url($this->payGateway->pay_handleroute . '/notify_url'),
|
|
||||||
];
|
|
||||||
$parameter['signature'] = $this->epusdtSign($parameter, $this->payGateway->merchant_id);
|
|
||||||
$client = new Client([
|
|
||||||
'headers' => [ 'Content-Type' => 'application/json' ]
|
|
||||||
]);
|
|
||||||
$response = $client->post($this->payGateway->merchant_pem, ['body' => json_encode($parameter)]);
|
|
||||||
$body = json_decode($response->getBody()->getContents(), true);
|
|
||||||
if (!isset($body['status_code']) || $body['status_code'] != 200) {
|
|
||||||
return $this->err(__('dujiaoka.prompt.abnormal_payment_channel') . $body['message']);
|
|
||||||
}
|
|
||||||
return redirect()->away($body['data']['payment_url']);
|
|
||||||
} catch (RuleValidationException $exception) {
|
|
||||||
} catch (GuzzleException $exception) {
|
|
||||||
return $this->err($exception->getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private function epusdtSign(array $parameter, string $signKey)
|
|
||||||
{
|
|
||||||
ksort($parameter);
|
|
||||||
reset($parameter); //内部指针指向数组中的第一个元素
|
|
||||||
$sign = '';
|
|
||||||
$urls = '';
|
|
||||||
foreach ($parameter as $key => $val) {
|
|
||||||
if ($val == '') continue;
|
|
||||||
if ($key != 'signature') {
|
|
||||||
if ($sign != '') {
|
|
||||||
$sign .= "&";
|
|
||||||
$urls .= "&";
|
|
||||||
}
|
|
||||||
$sign .= "$key=$val"; //拼接为url参数形式
|
|
||||||
$urls .= "$key=" . urlencode($val); //拼接为url参数形式
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$sign = md5($sign . $signKey);//密码追加进入开始MD5签名
|
|
||||||
return $sign;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function notifyUrl(Request $request)
|
|
||||||
{
|
|
||||||
$data = $request->all();
|
|
||||||
$order = $this->orderService->detailOrderSN($data['order_id']);
|
|
||||||
if (!$order) {
|
|
||||||
return 'fail';
|
|
||||||
}
|
|
||||||
$payGateway = $this->payService->detail($order->pay_id);
|
|
||||||
if (!$payGateway) {
|
|
||||||
return 'fail';
|
|
||||||
}
|
|
||||||
$signature = $this->epusdtSign($data, $payGateway->merchant_id);
|
|
||||||
if ($data['signature'] != $signature) { //不合法的数据
|
|
||||||
return 'fail'; //返回失败 继续补单
|
|
||||||
} else {
|
|
||||||
//合法的数据
|
|
||||||
//业务处理
|
|
||||||
$this->orderProcessService->completedOrder($data['order_id'], $data['amount'], $data['trade_id']);
|
|
||||||
return 'ok';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function returnUrl(Request $request)
|
|
||||||
{
|
|
||||||
$oid = $request->get('order_id');
|
|
||||||
// 异步通知还没到就跳转了,所以这里休眠2秒
|
|
||||||
sleep(2);
|
|
||||||
return redirect(url('detail-order-sn', ['orderSN' => $oid]));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
/**
|
|
||||||
* The file was created by Assimon.
|
|
||||||
*
|
|
||||||
* @author GMWalletApp<ashang@utf8.hk>
|
|
||||||
* @copyright GMWalletApp<ashang@utf8.hk>
|
|
||||||
* @link http://utf8.hk/
|
|
||||||
*/
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::get('pay-gateway/{handle}/{payway}/{orderSN}', 'PayController@redirectGateway');
|
|
||||||
|
|
||||||
// 支付相关
|
|
||||||
Route::group(['prefix' => 'pay', 'namespace' => 'Pay', 'middleware' => ['dujiaoka.pay_gate_way']], function () {
|
|
||||||
// 支付宝
|
|
||||||
Route::get('alipay/{payway}/{orderSN}', 'AlipayController@gateway');
|
|
||||||
Route::post('alipay/notify_url', 'AlipayController@notifyUrl');
|
|
||||||
// 微信
|
|
||||||
Route::get('wepay/{payway}/{orderSN}', 'WepayController@gateway');
|
|
||||||
Route::post('wepay/notify_url', 'WepayController@notifyUrl');
|
|
||||||
// 码支付
|
|
||||||
Route::get('mapay/{payway}/{orderSN}', 'MapayController@gateway');
|
|
||||||
Route::post('mapay/notify_url', 'MapayController@notifyUrl');
|
|
||||||
// Paysapi
|
|
||||||
Route::get('paysapi/{payway}/{orderSN}', 'PaysapiController@gateway');
|
|
||||||
Route::post('paysapi/notify_url', 'PaysapiController@notifyUrl');
|
|
||||||
Route::get('paysapi/return_url', 'PaysapiController@returnUrl')->name('paysapi-return');
|
|
||||||
// payjs
|
|
||||||
Route::get('payjs/{payway}/{orderSN}', 'PayjsController@gateway');
|
|
||||||
Route::post('payjs/notify_url', 'PayjsController@notifyUrl');
|
|
||||||
// 易支付
|
|
||||||
Route::get('yipay/{payway}/{orderSN}', 'YipayController@gateway');
|
|
||||||
Route::get('yipay/notify_url', 'YipayController@notifyUrl');
|
|
||||||
Route::get('yipay/return_url', 'YipayController@returnUrl')->name('yipay-return');
|
|
||||||
// paypal
|
|
||||||
Route::get('paypal/{payway}/{orderSN}', 'PaypalPayController@gateway');
|
|
||||||
Route::get('paypal/return_url', 'PaypalPayController@returnUrl')->name('paypal-return');
|
|
||||||
Route::any('paypal/notify_url', 'PaypalPayController@notifyUrl');
|
|
||||||
// V免签
|
|
||||||
Route::get('vpay/{payway}/{orderSN}', 'VpayController@gateway');
|
|
||||||
Route::get('vpay/notify_url', 'VpayController@notifyUrl');
|
|
||||||
Route::get('vpay/return_url', 'VpayController@returnUrl')->name('vpay-return');
|
|
||||||
// stripe
|
|
||||||
Route::get('stripe/{payway}/{oid}','StripeController@gateway');
|
|
||||||
Route::get('stripe/return_url','StripeController@returnUrl');
|
|
||||||
Route::get('stripe/check','StripeController@check');
|
|
||||||
Route::get('stripe/charge','StripeController@charge');
|
|
||||||
// Coinbase
|
|
||||||
Route::get('coinbase/{payway}/{orderSN}', 'CoinbaseController@gateway');
|
|
||||||
Route::post('coinbase/notify_url', 'CoinbaseController@notifyUrl');
|
|
||||||
// epusdt
|
|
||||||
Route::get('epusdt/{payway}/{orderSN}', 'EpusdtController@gateway');
|
|
||||||
Route::post('epusdt/notify_url', 'EpusdtController@notifyUrl');
|
|
||||||
Route::get('epusdt/return_url', 'EpusdtController@returnUrl')->name('epusdt-return');
|
|
||||||
|
|
||||||
});
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
-- auto-generated definition
|
|
||||||
create table orders
|
|
||||||
(
|
|
||||||
id int auto_increment
|
|
||||||
primary key,
|
|
||||||
trade_id varchar(32) not null comment 'epusdt订单号',
|
|
||||||
order_id varchar(32) not null comment '客户交易id',
|
|
||||||
block_transaction_id varchar(128) null comment '区块唯一编号',
|
|
||||||
actual_amount decimal(19, 4) not null comment '订单实际需要支付的金额,保留4位小数',
|
|
||||||
amount decimal(19, 4) not null comment '订单金额,保留4位小数',
|
|
||||||
token varchar(50) not null comment '所属钱包地址',
|
|
||||||
status int default 1 not null comment '1:等待支付,2:支付成功,3:已过期',
|
|
||||||
notify_url varchar(128) not null comment '异步回调地址',
|
|
||||||
redirect_url varchar(128) null comment '同步回调地址',
|
|
||||||
callback_num int default 0 null comment '回调次数',
|
|
||||||
callback_confirm int default 2 null comment '回调是否已确认? 1是 2否',
|
|
||||||
created_at timestamp null,
|
|
||||||
updated_at timestamp null,
|
|
||||||
deleted_at timestamp null,
|
|
||||||
constraint orders_order_id_uindex
|
|
||||||
unique (order_id),
|
|
||||||
constraint orders_trade_id_uindex
|
|
||||||
unique (trade_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
create index orders_block_transaction_id_index
|
|
||||||
on orders (block_transaction_id);
|
|
||||||
|
|
||||||
-- auto-generated definition
|
|
||||||
create table wallet_address
|
|
||||||
(
|
|
||||||
id int auto_increment
|
|
||||||
primary key,
|
|
||||||
token varchar(50) not null comment '钱包token',
|
|
||||||
status int default 1 not null comment '1:启用 2:禁用',
|
|
||||||
created_at timestamp null,
|
|
||||||
updated_at timestamp null,
|
|
||||||
deleted_at timestamp null
|
|
||||||
)
|
|
||||||
comment '钱包表';
|
|
||||||
|
|
||||||
create index wallet_address_token_index
|
|
||||||
on wallet_address (token);
|
|
||||||
|
|
||||||
@@ -20,27 +20,6 @@ db_type=sqlite
|
|||||||
sqlite_database_filename=
|
sqlite_database_filename=
|
||||||
sqlite_table_prefix=
|
sqlite_table_prefix=
|
||||||
|
|
||||||
# postgres config
|
|
||||||
postgres_host=127.0.0.1
|
|
||||||
postgres_port=3306
|
|
||||||
postgres_user=mysql_user
|
|
||||||
postgres_passwd=mysql_password
|
|
||||||
postgres_database=database_name
|
|
||||||
postgres_table_prefix=
|
|
||||||
postgres_max_idle_conns=10
|
|
||||||
postgres_max_open_conns=100
|
|
||||||
postgres_max_life_time=6
|
|
||||||
|
|
||||||
# mysql config
|
|
||||||
mysql_host=127.0.0.1
|
|
||||||
mysql_port=3306
|
|
||||||
mysql_user=mysql_user
|
|
||||||
mysql_passwd=mysql_password
|
|
||||||
mysql_database=database_name
|
|
||||||
mysql_table_prefix=
|
|
||||||
mysql_max_idle_conns=10
|
|
||||||
mysql_max_open_conns=100
|
|
||||||
mysql_max_life_time=6
|
|
||||||
|
|
||||||
# sqlite runtime store config
|
# sqlite runtime store config
|
||||||
runtime_sqlite_filename=epusdt-runtime.db
|
runtime_sqlite_filename=epusdt-runtime.db
|
||||||
|
|||||||
+2
-1
@@ -1,3 +1,4 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
dist/
|
dist/
|
||||||
*.log
|
*.log
|
||||||
|
bin/
|
||||||
@@ -15,7 +15,7 @@ builds:
|
|||||||
flags:
|
flags:
|
||||||
- -trimpath
|
- -trimpath
|
||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X github.com/assimon/luuu/config.BuildVersion={{ .Version }} -X github.com/assimon/luuu/config.BuildCommit={{ .Commit }} -X github.com/assimon/luuu/config.BuildDate={{ .Date }}
|
- -s -w -X github.com/GMWalletApp/epusdt/config.BuildVersion={{ .Tag }} -X github.com/GMWalletApp/epusdt/config.BuildCommit={{ .Commit }} -X github.com/GMWalletApp/epusdt/config.BuildDate={{ .Date }}
|
||||||
goos:
|
goos:
|
||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
APP := epusdt
|
||||||
|
TARGET_DIR := bin
|
||||||
|
BUILD_TAG ?= 0.0.0-dev
|
||||||
|
VERSION_PKG := github.com/GMWalletApp/epusdt/config
|
||||||
|
LDFLAGS := -s -w -X $(VERSION_PKG).BuildVersion=$(BUILD_TAG)
|
||||||
|
|
||||||
|
.PHONY: build release build-all swagger \
|
||||||
|
linux-amd64 linux-arm64 linux-armv7 \
|
||||||
|
darwin-amd64 darwin-arm64 \
|
||||||
|
windows-amd64 windows-arm64
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP) .
|
||||||
|
|
||||||
|
release: build
|
||||||
|
|
||||||
|
linux-amd64:
|
||||||
|
GOOS=linux GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-amd64 .
|
||||||
|
|
||||||
|
linux-arm64:
|
||||||
|
GOOS=linux GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-arm64 .
|
||||||
|
|
||||||
|
linux-armv7:
|
||||||
|
GOOS=linux GOARCH=arm GOARM=7 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-linux-armv7 .
|
||||||
|
|
||||||
|
darwin-amd64:
|
||||||
|
GOOS=darwin GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-darwin-amd64 .
|
||||||
|
|
||||||
|
darwin-arm64:
|
||||||
|
GOOS=darwin GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-darwin-arm64 .
|
||||||
|
|
||||||
|
windows-amd64:
|
||||||
|
GOOS=windows GOARCH=amd64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-windows-amd64.exe .
|
||||||
|
|
||||||
|
windows-arm64:
|
||||||
|
GOOS=windows GOARCH=arm64 go build --trimpath -ldflags="$(LDFLAGS)" -o $(TARGET_DIR)/$(APP)-windows-arm64.exe .
|
||||||
|
|
||||||
|
build-all: linux-amd64 linux-arm64 linux-armv7 darwin-amd64 darwin-arm64 windows-amd64 windows-arm64
|
||||||
|
|
||||||
|
swagger:
|
||||||
|
swag init --outputTypes json,yaml --parseInternal --parseDependency -o docs
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/mq"
|
"github.com/GMWalletApp/epusdt/mq"
|
||||||
"github.com/GMWalletApp/epusdt/task"
|
"github.com/GMWalletApp/epusdt/task"
|
||||||
"github.com/GMWalletApp/epusdt/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
@@ -21,8 +22,15 @@ func InitApp() {
|
|||||||
config.Init()
|
config.Init()
|
||||||
log.Init()
|
log.Init()
|
||||||
dao.Init()
|
dao.Init()
|
||||||
|
logLevel := data.GetSettingString(mdb.SettingKeySystemLogLevel, mdb.SettingDefaultSystemLogLevel)
|
||||||
|
if err := log.SetLevel(logLevel); err != nil {
|
||||||
|
color.Red.Printf("[bootstrap] apply log level setting %q err=%s; fallback=%s\n", logLevel, err, mdb.SettingDefaultSystemLogLevel)
|
||||||
|
if fallbackErr := log.SetLevel(mdb.SettingDefaultSystemLogLevel); fallbackErr != nil {
|
||||||
|
color.Red.Printf("[bootstrap] apply fallback log level err=%s\n", fallbackErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Wire settings-table lookups into the config package so
|
// Wire settings-table lookups into the config package so
|
||||||
// GetRateApiUrl / GetUsdtRate prefer DB-backed overrides.
|
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
|
||||||
config.SettingsGetString = func(key string) string {
|
config.SettingsGetString = func(key string) string {
|
||||||
return data.GetSettingString(key, "")
|
return data.GetSettingString(key, "")
|
||||||
}
|
}
|
||||||
@@ -36,19 +44,18 @@ func InitApp() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// config.Init() computes RateApiUrl before SettingsGetString is
|
|
||||||
// installed, so refresh the cache once DB-backed settings are available.
|
|
||||||
config.RateApiUrl = config.GetRateApiUrl()
|
|
||||||
// Seed admin account and JWT secret so the management console is
|
// Seed admin account and JWT secret so the management console is
|
||||||
// immediately usable on a fresh install. Both are idempotent.
|
// immediately usable on a fresh install. Both are idempotent.
|
||||||
_, isNew, err := data.EnsureDefaultAdmin()
|
initialPassword, isNew, err := data.EnsureDefaultAdmin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
|
color.Red.Printf("[bootstrap] ensure default admin err=%s\n", err)
|
||||||
}
|
}
|
||||||
if isNew {
|
if isNew {
|
||||||
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
|
color.Yellow.Println("╔════════════════════════════════════════════════════════════════════════╗")
|
||||||
color.Yellow.Println("║ Default admin account created. Fetch one-time password via API first!║")
|
color.Yellow.Println("║ Default admin account created. Save these credentials now. ║")
|
||||||
color.Yellow.Printf("║ Username: admin ║\n")
|
color.Yellow.Printf("║ Username: %-54s║\n", "admin")
|
||||||
|
color.Yellow.Printf("║ Password: %-54s║\n", initialPassword)
|
||||||
|
color.Yellow.Println("║ The one-time password API remains available until first fetch. ║")
|
||||||
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
|
color.Yellow.Println("║ GET /admin/api/v1/auth/init-password (one-time) ║")
|
||||||
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
|
color.Yellow.Println("╚════════════════════════════════════════════════════════════════════════╝")
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-35
@@ -3,11 +3,11 @@ package config
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,12 +16,6 @@ import (
|
|||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseFloat is the minimal helper used by the settings bridge; kept
|
|
||||||
// here so settings_bridge.go stays import-free.
|
|
||||||
func parseFloat(s string) (float64, error) {
|
|
||||||
return strconv.ParseFloat(strings.TrimSpace(s), 64)
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
HTTPAccessLog bool
|
HTTPAccessLog bool
|
||||||
SQLDebug bool
|
SQLDebug bool
|
||||||
@@ -34,8 +28,6 @@ var (
|
|||||||
TgBotToken string
|
TgBotToken string
|
||||||
TgProxy string
|
TgProxy string
|
||||||
TgManage int64
|
TgManage int64
|
||||||
UsdtRate float64
|
|
||||||
RateApiUrl string
|
|
||||||
BuildVersion = "0.0.0-dev"
|
BuildVersion = "0.0.0-dev"
|
||||||
BuildCommit = "none"
|
BuildCommit = "none"
|
||||||
BuildDate = "unknown"
|
BuildDate = "unknown"
|
||||||
@@ -64,6 +56,9 @@ func Init() {
|
|||||||
LogLevel = normalizeLogLevel(viper.GetString("log_level"))
|
LogLevel = normalizeLogLevel(viper.GetString("log_level"))
|
||||||
StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
|
StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
|
||||||
StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/"))
|
StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/"))
|
||||||
|
if err = ensureConfiguredStaticFiles(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
|
RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
|
||||||
LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
|
LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
|
||||||
mustMkdir(RuntimePath)
|
mustMkdir(RuntimePath)
|
||||||
@@ -76,8 +71,6 @@ func Init() {
|
|||||||
TgBotToken = viper.GetString("tg_bot_token")
|
TgBotToken = viper.GetString("tg_bot_token")
|
||||||
TgProxy = viper.GetString("tg_proxy")
|
TgProxy = viper.GetString("tg_proxy")
|
||||||
TgManage = viper.GetInt64("tg_manage")
|
TgManage = viper.GetInt64("tg_manage")
|
||||||
|
|
||||||
RateApiUrl = GetRateApiUrl()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustMkdir(path string) {
|
func mustMkdir(path string) {
|
||||||
@@ -86,6 +79,93 @@ func mustMkdir(path string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureConfiguredStaticFiles() error {
|
||||||
|
if strings.TrimSpace(StaticFilePath) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
exePath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
exePath, err = filepath.EvalSymlinks(exePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
srcDir := filepath.Join(filepath.Dir(exePath), "static")
|
||||||
|
srcInfo, err := os.Stat(srcDir)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !srcInfo.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
srcAbs, err := filepath.Abs(srcDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dstAbs, err := filepath.Abs(StaticFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if srcAbs == dstAbs {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return copyMissingStaticFiles(srcAbs, dstAbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyMissingStaticFiles(srcDir, dstDir string) error {
|
||||||
|
return filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(srcDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dstPath := filepath.Join(dstDir, rel)
|
||||||
|
if d.IsDir() {
|
||||||
|
return os.MkdirAll(dstPath, 0o755)
|
||||||
|
}
|
||||||
|
if _, err = os.Stat(dstPath); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return copyFile(path, dstPath)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyFile(srcPath, dstPath string) error {
|
||||||
|
in, err := os.Open(srcPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
|
||||||
|
out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err = io.Copy(out, in); err != nil {
|
||||||
|
_ = out.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeLogLevel(level string) string {
|
func normalizeLogLevel(level string) string {
|
||||||
switch strings.ToLower(strings.TrimSpace(level)) {
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
case "debug", "info", "warn", "error":
|
case "debug", "info", "warn", "error":
|
||||||
@@ -266,22 +346,33 @@ func GetRateForCoin(coin string, base string) float64 {
|
|||||||
if coin == base {
|
if coin == base {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
if coin == "usdt" {
|
if forcedRate := getForcedRateForCoin(coin, base); forcedRate > 0 {
|
||||||
switch base {
|
return forcedRate
|
||||||
case "usd":
|
}
|
||||||
return 1
|
if coin == "usdt" && base == "usd" {
|
||||||
case "cny":
|
return 1
|
||||||
usdtRate := GetUsdtRate()
|
}
|
||||||
if usdtRate > 0 {
|
if rate := getRateForCoinFromAPI(coin, base); rate > 0 {
|
||||||
return 1 / usdtRate
|
return rate
|
||||||
}
|
}
|
||||||
|
if coin == "ton" {
|
||||||
|
return getRateForCoinFromAPI("toncoin", base)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getForcedRateForCoin(coin string, base string) float64 {
|
||||||
|
raw := strings.TrimSpace(settingsForcedRateList())
|
||||||
|
if raw != "" {
|
||||||
|
if rate := gjson.Get(raw, base+"."+coin).Float(); rate > 0 {
|
||||||
|
return rate
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
baseURL := RateApiUrl
|
func getRateForCoinFromAPI(coin string, base string) float64 {
|
||||||
if baseURL == "" {
|
baseURL := GetRateApiUrl()
|
||||||
baseURL = GetRateApiUrl()
|
|
||||||
}
|
|
||||||
if baseURL == "" {
|
if baseURL == "" {
|
||||||
log.Printf("rate api url is empty")
|
log.Printf("rate api url is empty")
|
||||||
return 0.0
|
return 0.0
|
||||||
@@ -313,19 +404,18 @@ func GetRateForCoin(coin string, base string) float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func GetUsdtRate() float64 {
|
func GetUsdtRate() float64 {
|
||||||
// Prefer the DB-backed override (admin-configurable). Fall back to
|
// rate.forced_rate_list stores token amount per one base currency unit.
|
||||||
// the legacy .env var, then the hardcoded 6.4 default.
|
// GetUsdtRate returns the inverse display value: CNY per USDT.
|
||||||
if forced := settingsForcedUsdtRate(); forced > 0 {
|
if forcedRate := getForcedRateForCoin("usdt", "cny"); forcedRate > 0 {
|
||||||
return forced
|
return 1 / forcedRate
|
||||||
}
|
}
|
||||||
forcedUsdtRate := viper.GetFloat64("forced_usdt_rate")
|
|
||||||
if forcedUsdtRate > 0 {
|
apiRate := getRateForCoinFromAPI("usdt", "cny")
|
||||||
return forcedUsdtRate
|
if apiRate > 0 {
|
||||||
|
return 1 / apiRate
|
||||||
}
|
}
|
||||||
if UsdtRate <= 0 {
|
log.Printf("usdt/cny rate unavailable: rate.forced_rate_list has no positive cny.usdt entry and rate api returned no data")
|
||||||
return 6.4
|
return 0
|
||||||
}
|
|
||||||
return UsdtRate
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetOrderExpirationTime() int {
|
func GetOrderExpirationTime() int {
|
||||||
|
|||||||
@@ -1,11 +1,52 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
|
"github.com/go-resty/resty/v2"
|
||||||
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func installSettingsGetter(t *testing.T, values map[string]string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldGetter := SettingsGetString
|
||||||
|
SettingsGetString = func(key string) string {
|
||||||
|
return values[key]
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
SettingsGetString = oldGetter
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||||
|
|
||||||
|
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return f(req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
oldFactory := http_client.ClientFactory
|
||||||
|
http_client.ClientFactory = func() *resty.Client {
|
||||||
|
client := resty.NewWithClient(&http.Client{Transport: handler})
|
||||||
|
client.SetTimeout(10 * time.Second)
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
http_client.ClientFactory = oldFactory
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) {
|
func TestNormalizeConfiguredPathUsesExplicitFile(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -135,3 +176,242 @@ func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
|
|||||||
t.Fatalf("config path = %s, want %s", got, flagPath)
|
t.Fatalf("config path = %s, want %s", got, flagPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCopyMissingStaticFilesCopiesAssetsWithoutOverwriting(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
src := filepath.Join(root, "app-static")
|
||||||
|
dst := filepath.Join(root, "data-static")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Join(src, "images"), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir source: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "index.html"), []byte("source-index"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write source index: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "payment.js"), []byte("source-payment"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write source payment: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(src, "images", "logo.png"), []byte("source-logo"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write source logo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir destination: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dst, "index.html"), []byte("custom-index"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write existing destination index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := copyMissingStaticFiles(src, dst); err != nil {
|
||||||
|
t.Fatalf("copy missing static files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
index, err := os.ReadFile(filepath.Join(dst, "index.html"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read destination index: %v", err)
|
||||||
|
}
|
||||||
|
if string(index) != "custom-index" {
|
||||||
|
t.Fatalf("existing index was overwritten: %q", string(index))
|
||||||
|
}
|
||||||
|
|
||||||
|
payment, err := os.ReadFile(filepath.Join(dst, "payment.js"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read copied payment: %v", err)
|
||||||
|
}
|
||||||
|
if string(payment) != "source-payment" {
|
||||||
|
t.Fatalf("payment.js = %q, want source-payment", string(payment))
|
||||||
|
}
|
||||||
|
|
||||||
|
logo, err := os.ReadFile(filepath.Join(dst, "images", "logo.png"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read copied nested asset: %v", err)
|
||||||
|
}
|
||||||
|
if string(logo) != "source-logo" {
|
||||||
|
t.Fatalf("logo.png = %q, want source-logo", string(logo))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUsdtRatePrefersForcedRateList(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Cleanup(viper.Reset)
|
||||||
|
t.Setenv("API_RATE_URL", "")
|
||||||
|
|
||||||
|
apiCalled := false
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
apiCalled = true
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusInternalServerError,
|
||||||
|
Status: http.StatusText(http.StatusInternalServerError),
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("")),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
installSettingsGetter(t, map[string]string{
|
||||||
|
"rate.forced_rate_list": `{"cny":{"usdt":0.13793103448275862},"usd":{"trx":123.45}}`,
|
||||||
|
"rate.api_url": "https://rate.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := GetUsdtRate()
|
||||||
|
if math.Abs(got-7.25) > 1e-9 {
|
||||||
|
t.Fatalf("GetUsdtRate() = %v, want 7.25", got)
|
||||||
|
}
|
||||||
|
if rate := GetRateForCoin("trx", "usd"); rate != 123.45 {
|
||||||
|
t.Fatalf("GetRateForCoin(trx, usd) = %v, want 123.45", rate)
|
||||||
|
}
|
||||||
|
if apiCalled {
|
||||||
|
t.Fatalf("rate API should not be called when rate.forced_rate_list has positive rate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Cleanup(viper.Reset)
|
||||||
|
t.Setenv("API_RATE_URL", "")
|
||||||
|
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path != "/cny.json" {
|
||||||
|
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
installSettingsGetter(t, map[string]string{
|
||||||
|
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
|
||||||
|
"rate.api_url": "https://rate.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
got := GetUsdtRate()
|
||||||
|
want := 1 / 0.14635
|
||||||
|
if math.Abs(got-want) > 1e-9 {
|
||||||
|
t.Fatalf("GetUsdtRate() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := GetRateForCoin("usdt", "cny")
|
||||||
|
if math.Abs(rate-0.14635) > 1e-9 {
|
||||||
|
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0.14635", rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRateForCoinUsesAPIWhenForcedPairMissing(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Cleanup(viper.Reset)
|
||||||
|
t.Setenv("API_RATE_URL", "")
|
||||||
|
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path != "/cny.json" {
|
||||||
|
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
installSettingsGetter(t, map[string]string{
|
||||||
|
"rate.forced_rate_list": `{"usd":{"trx":123.45}}`,
|
||||||
|
"rate.api_url": "https://rate.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
rate := GetRateForCoin("usdt", "cny")
|
||||||
|
if math.Abs(rate-0.14635) > 1e-9 {
|
||||||
|
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0.14635", rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRateForCoinUsesToncoinAPIFallbackForTon(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Cleanup(viper.Reset)
|
||||||
|
t.Setenv("API_RATE_URL", "")
|
||||||
|
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path != "/cny.json" {
|
||||||
|
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"cny":{"toncoin":1.82}}`)),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
installSettingsGetter(t, map[string]string{
|
||||||
|
"rate.forced_rate_list": `{"cny":{"ton":0}}`,
|
||||||
|
"rate.api_url": "https://rate.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
rate := GetRateForCoin("ton", "cny")
|
||||||
|
if math.Abs(rate-1.82) > 1e-9 {
|
||||||
|
t.Fatalf("GetRateForCoin(ton, cny) = %v, want 1.82", rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Cleanup(viper.Reset)
|
||||||
|
t.Setenv("API_RATE_URL", "")
|
||||||
|
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusBadGateway,
|
||||||
|
Status: "502 Bad Gateway",
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("")),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
installSettingsGetter(t, map[string]string{
|
||||||
|
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
|
||||||
|
"rate.api_url": "https://rate.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
if got := GetUsdtRate(); got != 0 {
|
||||||
|
t.Fatalf("GetUsdtRate() = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got := GetRateForCoin("usdt", "cny"); got != 0 {
|
||||||
|
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Cleanup(viper.Reset)
|
||||||
|
t.Setenv("API_RATE_URL", "")
|
||||||
|
|
||||||
|
callCount := 0
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
callCount++
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusBadGateway,
|
||||||
|
Status: "502 Bad Gateway",
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("")),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
installSettingsGetter(t, map[string]string{
|
||||||
|
"rate.forced_rate_list": `{"cny":{"usdt":0}}`,
|
||||||
|
"rate.api_url": "https://rate.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
if got := GetRateForCoin("usdt", "cny"); got != 0 {
|
||||||
|
t.Fatalf("GetRateForCoin(usdt, cny) = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if callCount != 1 {
|
||||||
|
t.Fatalf("rate api call count = %d, want 1", callCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ package config
|
|||||||
// create a cycle via `model/dao -> config`.
|
// create a cycle via `model/dao -> config`.
|
||||||
//
|
//
|
||||||
// If unset (e.g. during early startup or tests) the getters return the
|
// If unset (e.g. during early startup or tests) the getters return the
|
||||||
// zero string / 0 and callers fall through to the .env / default path.
|
// zero string / 0 and callers apply their own fallback behavior.
|
||||||
|
|
||||||
// SettingsGetString is installed by bootstrap.Init with a closure that
|
// SettingsGetString is installed by bootstrap.Init with a closure that
|
||||||
// reads from the settings table. Runtime-only.
|
// reads from the settings table. Runtime-only.
|
||||||
@@ -18,17 +18,9 @@ func settingsRateApiUrl() string {
|
|||||||
return SettingsGetString("rate.api_url")
|
return SettingsGetString("rate.api_url")
|
||||||
}
|
}
|
||||||
|
|
||||||
func settingsForcedUsdtRate() float64 {
|
func settingsForcedRateList() string {
|
||||||
if SettingsGetString == nil {
|
if SettingsGetString == nil {
|
||||||
return 0
|
return ""
|
||||||
}
|
}
|
||||||
raw := SettingsGetString("rate.forced_usdt_rate")
|
return SettingsGetString("rate.forced_rate_list")
|
||||||
if raw == "" {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
f, err := parseFloat(raw)
|
|
||||||
if err != nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return f
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,12 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ func (c *BaseAdminController) ListApiKeys(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error {
|
func (c *BaseAdminController) CreateApiKey(ctx echo.Context) error {
|
||||||
req := new(CreateApiKeyRequest)
|
req := new(CreateApiKeyRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -150,11 +150,11 @@ func isUniqueViolation(err error) bool {
|
|||||||
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
req := new(UpdateApiKeyRequest)
|
req := new(UpdateApiKeyRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
@@ -187,11 +187,11 @@ func (c *BaseAdminController) UpdateApiKey(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
|
func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
req := new(ChangeApiKeyStatusRequest)
|
req := new(ChangeApiKeyStatusRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -215,7 +215,7 @@ func (c *BaseAdminController) ChangeApiKeyStatus(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
|
func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
secret := randomHex(32)
|
secret := randomHex(32)
|
||||||
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil {
|
if err := data.UpdateApiKeyFields(id, map[string]interface{}{"secret_key": secret}); err != nil {
|
||||||
@@ -239,7 +239,7 @@ func (c *BaseAdminController) RotateApiKeySecret(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
|
func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := data.DeleteApiKeyByID(id); err != nil {
|
if err := data.DeleteApiKeyByID(id); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -262,14 +262,14 @@ func (c *BaseAdminController) DeleteApiKey(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
|
func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
row, err := data.GetApiKeyByID(id)
|
row, err := data.GetApiKeyByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if row.ID == 0 {
|
if row.ID == 0 {
|
||||||
return c.FailJson(ctx, errors.New("api key not found"))
|
return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
|
||||||
}
|
}
|
||||||
return c.SucJson(ctx, map[string]interface{}{
|
return c.SucJson(ctx, map[string]interface{}{
|
||||||
"call_count": row.CallCount,
|
"call_count": row.CallCount,
|
||||||
@@ -291,14 +291,14 @@ func (c *BaseAdminController) GetApiKeyStats(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
|
func (c *BaseAdminController) GetApiKeySecret(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
row, err := data.GetApiKeyByID(id)
|
row, err := data.GetApiKeyByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if row.ID == 0 {
|
if row.ID == 0 {
|
||||||
return c.FailJson(ctx, errors.New("api key not found"))
|
return c.FailJson(ctx, constant.ApiKeyNotFoundErr)
|
||||||
}
|
}
|
||||||
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
|
return c.SucJson(ctx, map[string]string{"secret_key": row.SecretKey})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,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/util/constant"
|
||||||
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
@@ -53,7 +54,7 @@ type InitialPasswordResponse struct {
|
|||||||
func (c *BaseAdminController) Login(ctx echo.Context) error {
|
func (c *BaseAdminController) Login(ctx echo.Context) error {
|
||||||
req := new(LoginRequest)
|
req := new(LoginRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -63,10 +64,10 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
|
|||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) {
|
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.Password) {
|
||||||
return c.FailJson(ctx, errors.New("invalid username or password"))
|
return c.FailJson(ctx, constant.AdminInvalidCredentialErr)
|
||||||
}
|
}
|
||||||
if user.Status != mdb.AdminUserStatusEnable {
|
if user.Status != mdb.AdminUserStatusEnable {
|
||||||
return c.FailJson(ctx, errors.New("user disabled"))
|
return c.FailJson(ctx, constant.AdminUserDisabledErr)
|
||||||
}
|
}
|
||||||
token, err := appjwt.Sign(user.ID, user.Username)
|
token, err := appjwt.Sign(user.ID, user.Username)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -94,6 +95,9 @@ func (c *BaseAdminController) Login(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
|
func (c *BaseAdminController) GetInitialPassword(ctx echo.Context) error {
|
||||||
password, err := data.ConsumeInitialAdminPassword()
|
password, err := data.ConsumeInitialAdminPassword()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, data.ErrInitAdminPasswordAlreadyFetched) || errors.Is(err, data.ErrInitAdminPasswordUnavailable) {
|
||||||
|
return c.FailJson(ctx, constant.InitialAdminPasswordErr)
|
||||||
|
}
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
return c.SucJson(ctx, InitialPasswordResponse{
|
return c.SucJson(ctx, InitialPasswordResponse{
|
||||||
@@ -144,14 +148,14 @@ func (c *BaseAdminController) Logout(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) Me(ctx echo.Context) error {
|
func (c *BaseAdminController) Me(ctx echo.Context) error {
|
||||||
uid := currentAdminUserID(ctx)
|
uid := currentAdminUserID(ctx)
|
||||||
if uid == 0 {
|
if uid == 0 {
|
||||||
return c.FailJson(ctx, errors.New("unauthorized"))
|
return c.FailJson(ctx, constant.AdminUnauthorizedErr)
|
||||||
}
|
}
|
||||||
user, err := data.GetAdminUserByID(uid)
|
user, err := data.GetAdminUserByID(uid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if user.ID == 0 {
|
if user.ID == 0 {
|
||||||
return c.FailJson(ctx, errors.New("user not found"))
|
return c.FailJson(ctx, constant.AdminUserNotFoundErr)
|
||||||
}
|
}
|
||||||
// Warn the frontend when the operator hasn't changed the seeded
|
// Warn the frontend when the operator hasn't changed the seeded
|
||||||
// initial password.
|
// initial password.
|
||||||
@@ -176,11 +180,11 @@ func (c *BaseAdminController) Me(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
|
func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
|
||||||
uid := currentAdminUserID(ctx)
|
uid := currentAdminUserID(ctx)
|
||||||
if uid == 0 {
|
if uid == 0 {
|
||||||
return c.FailJson(ctx, errors.New("unauthorized"))
|
return c.FailJson(ctx, constant.AdminUnauthorizedErr)
|
||||||
}
|
}
|
||||||
req := new(ChangePasswordRequest)
|
req := new(ChangePasswordRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -190,7 +194,7 @@ func (c *BaseAdminController) ChangePassword(ctx echo.Context) error {
|
|||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) {
|
if user.ID == 0 || !data.VerifyPassword(user.PasswordHash, req.OldPassword) {
|
||||||
return c.FailJson(ctx, errors.New("old password incorrect"))
|
return c.FailJson(ctx, constant.AdminOldPasswordErr)
|
||||||
}
|
}
|
||||||
if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil {
|
if err := data.UpdateAdminUserPassword(uid, req.NewPassword); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -13,8 +14,7 @@ type UpdateChainRequest struct {
|
|||||||
DisplayName *string `json:"display_name" example:"Tron"`
|
DisplayName *string `json:"display_name" example:"Tron"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListChains returns the chains table (tron/ethereum/solana seeded; new
|
// ListChains returns the chains table for all seeded supported networks.
|
||||||
// networks like bsc/polygon inserted manually once supported).
|
|
||||||
// @Summary List chains
|
// @Summary List chains
|
||||||
// @Description Returns all supported blockchain networks
|
// @Description Returns all supported blockchain networks
|
||||||
// @Tags Admin Chains
|
// @Tags Admin Chains
|
||||||
@@ -40,7 +40,7 @@ func (c *BaseAdminController) ListChains(ctx echo.Context) error {
|
|||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param network path string true "Network name (e.g. tron, ethereum, solana)"
|
// @Param network path string true "Network name (e.g. ton, tron, ethereum, solana, aptos)"
|
||||||
// @Param request body admin.UpdateChainRequest true "Chain settings"
|
// @Param request body admin.UpdateChainRequest true "Chain settings"
|
||||||
// @Success 200 {object} response.ApiResponse
|
// @Success 200 {object} response.ApiResponse
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse
|
||||||
@@ -49,7 +49,7 @@ func (c *BaseAdminController) UpdateChain(ctx echo.Context) error {
|
|||||||
network := ctx.Param("network")
|
network := ctx.Param("network")
|
||||||
req := new(UpdateChainRequest)
|
req := new(UpdateChainRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
|
|||||||
@@ -6,13 +6,16 @@ import (
|
|||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateChainTokenRequest is the payload for creating a chain token.
|
// CreateChainTokenRequest is the payload for creating a chain token.
|
||||||
type CreateChainTokenRequest struct {
|
type CreateChainTokenRequest struct {
|
||||||
Network string `json:"network" validate:"required" example:"tron"`
|
Network string `json:"network" validate:"required" example:"tron"`
|
||||||
Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"`
|
Symbol string `json:"symbol" validate:"required|maxLen:32" example:"USDT"`
|
||||||
|
// Contract address or chain-native asset identifier. For Aptos this is
|
||||||
|
// the Move asset id (coin type, metadata address, or object type).
|
||||||
ContractAddress string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
ContractAddress string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
||||||
Decimals int `json:"decimals" example:"6"`
|
Decimals int `json:"decimals" example:"6"`
|
||||||
Enabled *bool `json:"enabled" example:"true"`
|
Enabled *bool `json:"enabled" example:"true"`
|
||||||
@@ -21,6 +24,8 @@ type CreateChainTokenRequest struct {
|
|||||||
|
|
||||||
// UpdateChainTokenRequest is the payload for updating a chain token.
|
// UpdateChainTokenRequest is the payload for updating a chain token.
|
||||||
type UpdateChainTokenRequest struct {
|
type UpdateChainTokenRequest struct {
|
||||||
|
// Contract address or chain-native asset identifier. For Aptos this is
|
||||||
|
// the Move asset id (coin type, metadata address, or object type).
|
||||||
ContractAddress *string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
ContractAddress *string `json:"contract_address" example:"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"`
|
||||||
Decimals *int `json:"decimals" example:"6"`
|
Decimals *int `json:"decimals" example:"6"`
|
||||||
Enabled *bool `json:"enabled" example:"true"`
|
Enabled *bool `json:"enabled" example:"true"`
|
||||||
@@ -32,6 +37,17 @@ type ChangeChainTokenStatusRequest struct {
|
|||||||
Enabled bool `json:"enabled" example:"true"`
|
Enabled bool `json:"enabled" example:"true"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateChainTokenPaymentConfig(row *mdb.ChainToken) error {
|
||||||
|
if row == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
contractAddress := strings.TrimSpace(row.ContractAddress)
|
||||||
|
if row.Enabled && row.Network == mdb.NetworkAptos && contractAddress == "" {
|
||||||
|
return constant.ParamsMarshalErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListChainTokens returns rows, optionally filtered by network.
|
// ListChainTokens returns rows, optionally filtered by network.
|
||||||
// @Summary List chain tokens
|
// @Summary List chain tokens
|
||||||
// @Description Returns chain tokens, optionally filtered by network
|
// @Description Returns chain tokens, optionally filtered by network
|
||||||
@@ -53,7 +69,7 @@ func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
|
|||||||
|
|
||||||
// CreateChainToken inserts a new row. Default decimals=6 when 0 passed.
|
// CreateChainToken inserts a new row. Default decimals=6 when 0 passed.
|
||||||
// @Summary Create chain token
|
// @Summary Create chain token
|
||||||
// @Description Create a new chain token entry
|
// @Description Create a new chain token entry. For Aptos, contract_address stores the Move asset id rather than an EVM contract address.
|
||||||
// @Tags Admin Chain Tokens
|
// @Tags Admin Chain Tokens
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -65,7 +81,7 @@ func (c *BaseAdminController) ListChainTokens(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
||||||
req := new(CreateChainTokenRequest)
|
req := new(CreateChainTokenRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -86,6 +102,9 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
|||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
MinAmount: req.MinAmount,
|
MinAmount: req.MinAmount,
|
||||||
}
|
}
|
||||||
|
if err := validateChainTokenPaymentConfig(row); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
if err := data.CreateChainToken(row); err != nil {
|
if err := data.CreateChainToken(row); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
@@ -94,7 +113,7 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
|||||||
|
|
||||||
// UpdateChainToken patches mutable columns.
|
// UpdateChainToken patches mutable columns.
|
||||||
// @Summary Update chain token
|
// @Summary Update chain token
|
||||||
// @Description Patch chain token fields
|
// @Description Patch chain token fields. For Aptos, contract_address stores the Move asset id rather than an EVM contract address.
|
||||||
// @Tags Admin Chain Tokens
|
// @Tags Admin Chain Tokens
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -107,25 +126,42 @@ func (c *BaseAdminController) CreateChainToken(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
req := new(UpdateChainTokenRequest)
|
req := new(UpdateChainTokenRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
|
existing, err := data.GetChainTokenByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if existing == nil {
|
||||||
|
existing = &mdb.ChainToken{}
|
||||||
|
}
|
||||||
|
next := *existing
|
||||||
if req.ContractAddress != nil {
|
if req.ContractAddress != nil {
|
||||||
fields["contract_address"] = *req.ContractAddress
|
next.ContractAddress = strings.TrimSpace(*req.ContractAddress)
|
||||||
|
fields["contract_address"] = next.ContractAddress
|
||||||
}
|
}
|
||||||
if req.Decimals != nil {
|
if req.Decimals != nil {
|
||||||
|
next.Decimals = *req.Decimals
|
||||||
fields["decimals"] = *req.Decimals
|
fields["decimals"] = *req.Decimals
|
||||||
}
|
}
|
||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
|
next.Enabled = *req.Enabled
|
||||||
fields["enabled"] = *req.Enabled
|
fields["enabled"] = *req.Enabled
|
||||||
}
|
}
|
||||||
if req.MinAmount != nil {
|
if req.MinAmount != nil {
|
||||||
|
next.MinAmount = *req.MinAmount
|
||||||
fields["min_amount"] = *req.MinAmount
|
fields["min_amount"] = *req.MinAmount
|
||||||
}
|
}
|
||||||
|
if next.ID > 0 {
|
||||||
|
if err = validateChainTokenPaymentConfig(&next); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := data.UpdateChainTokenFields(id, fields); err != nil {
|
if err := data.UpdateChainTokenFields(id, fields); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
@@ -147,12 +183,23 @@ func (c *BaseAdminController) UpdateChainToken(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
req := new(ChangeChainTokenStatusRequest)
|
req := new(ChangeChainTokenStatusRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
|
}
|
||||||
|
row, err := data.GetChainTokenByID(id)
|
||||||
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
if row != nil && row.ID > 0 {
|
||||||
|
next := *row
|
||||||
|
next.Enabled = req.Enabled
|
||||||
|
if err = validateChainTokenPaymentConfig(&next); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
if err := data.UpdateChainTokenFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
@@ -172,7 +219,7 @@ func (c *BaseAdminController) ChangeChainTokenStatus(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error {
|
func (c *BaseAdminController) DeleteChainToken(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := data.DeleteChainTokenByID(id); err != nil {
|
if err := data.DeleteChainTokenByID(id); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateChainTokenPaymentConfigRejectsEnabledAptosTokenWithoutAssetID(t *testing.T) {
|
||||||
|
for _, row := range []mdb.ChainToken{
|
||||||
|
{Network: mdb.NetworkAptos, Symbol: "USDT", Enabled: true},
|
||||||
|
} {
|
||||||
|
if err := validateChainTokenPaymentConfig(&row); err != constant.ParamsMarshalErr {
|
||||||
|
t.Fatalf("validateChainTokenPaymentConfig(%+v) = %v, want %v", row, err, constant.ParamsMarshalErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateChainTokenPaymentConfigAllowsDisabledAptosTokenWithoutAssetID(t *testing.T) {
|
||||||
|
row := mdb.ChainToken{Network: mdb.NetworkAptos, Symbol: "USDT", Enabled: false}
|
||||||
|
if err := validateChainTokenPaymentConfig(&row); err != nil {
|
||||||
|
t.Fatalf("validateChainTokenPaymentConfig(%+v) = %v, want nil", row, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,6 +32,49 @@ type OrderStatsResponse struct {
|
|||||||
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
|
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RpcStatsDashboardResponse is the RPC runtime stats payload.
|
||||||
|
type RpcStatsDashboardResponse struct {
|
||||||
|
GeneratedAt string `json:"generated_at" example:"2026-06-09T12:00:00+08:00"`
|
||||||
|
Summary RpcStatsSummaryResponse `json:"summary"`
|
||||||
|
Chains []RpcStatsChainResponse `json:"chains"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RpcStatsSummaryResponse is the overall RPC runtime summary.
|
||||||
|
type RpcStatsSummaryResponse struct {
|
||||||
|
TotalChains int `json:"total_chains" example:"6"`
|
||||||
|
EnabledChains int `json:"enabled_chains" example:"5"`
|
||||||
|
ActiveChains int `json:"active_chains" example:"4"`
|
||||||
|
OkChains int `json:"ok_chains" example:"3"`
|
||||||
|
WarningChains int `json:"warning_chains" example:"1"`
|
||||||
|
DownChains int `json:"down_chains" example:"0"`
|
||||||
|
UnknownChains int `json:"unknown_chains" example:"1"`
|
||||||
|
DisabledChains int `json:"disabled_chains" example:"1"`
|
||||||
|
SuccessCount int64 `json:"success_count" example:"123"`
|
||||||
|
FailureCount int64 `json:"failure_count" example:"2"`
|
||||||
|
SuccessRate float64 `json:"success_rate" example:"0.984"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RpcStatsChainResponse is one chain's in-process RPC runtime stats.
|
||||||
|
type RpcStatsChainResponse struct {
|
||||||
|
Network string `json:"network" example:"tron"`
|
||||||
|
DisplayName string `json:"display_name" example:"TRON"`
|
||||||
|
Enabled bool `json:"enabled" example:"true"`
|
||||||
|
HasData bool `json:"has_data" example:"true"`
|
||||||
|
Status string `json:"status" example:"ok" enums:"ok,warning,down,unknown,disabled"`
|
||||||
|
SuccessCount int64 `json:"success_count" example:"123"`
|
||||||
|
FailureCount int64 `json:"failure_count" example:"2"`
|
||||||
|
SuccessRate float64 `json:"success_rate" example:"0.984"`
|
||||||
|
LatestBlockHeight int64 `json:"latest_block_height" example:"64532100"`
|
||||||
|
LastSyncAt string `json:"last_sync_at" example:"2026-06-09T12:00:00+08:00"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcStatsStreamEnvelope struct {
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data RpcStatsDashboardResponse `json:"data"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
}
|
||||||
|
|
||||||
// RpcHealthCheckResponse is the response for a health check probe.
|
// RpcHealthCheckResponse is the response for a health check probe.
|
||||||
type RpcHealthCheckResponse struct {
|
type RpcHealthCheckResponse struct {
|
||||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||||
@@ -49,16 +95,18 @@ type RotateSecretResponse struct {
|
|||||||
|
|
||||||
// BatchImportResult is one row in the batch import response.
|
// BatchImportResult is one row in the batch import response.
|
||||||
type BatchImportResult struct {
|
type BatchImportResult struct {
|
||||||
Address string `json:"address" example:"TTestTronAddress001"`
|
Address string `json:"address" example:"TTestTronAddress001"`
|
||||||
OK bool `json:"ok" example:"true"`
|
OK bool `json:"ok" example:"true"`
|
||||||
Error string `json:"error,omitempty" example:""`
|
ErrorCode int `json:"error_code,omitempty" example:"10001"`
|
||||||
|
Error string `json:"error,omitempty" example:"wallet address already exists"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsUpsertResult is one row in the settings upsert response.
|
// SettingsUpsertResult is one row in the settings upsert response.
|
||||||
type SettingsUpsertResult struct {
|
type SettingsUpsertResult struct {
|
||||||
Key string `json:"key" example:"rate.api_url"`
|
Key string `json:"key" example:"rate.api_url"`
|
||||||
OK bool `json:"ok" example:"true"`
|
OK bool `json:"ok" example:"true"`
|
||||||
Error string `json:"error,omitempty" example:""`
|
ErrorCode int `json:"error_code,omitempty" example:"10043"`
|
||||||
|
Error string `json:"error,omitempty" example:"invalid setting item"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overview returns the top-card numbers for the dashboard landing page.
|
// Overview returns the top-card numbers for the dashboard landing page.
|
||||||
@@ -228,6 +276,174 @@ func (c *BaseAdminController) OrderStats(ctx echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RpcStats returns process-local RPC runtime counters grouped by chain.
|
||||||
|
// Values reset on process restart and are not persisted to the database.
|
||||||
|
// @Summary RPC runtime statistics
|
||||||
|
// @Description Streams in-process RPC success/failure counts, success rate, latest block height and last sync time by chain over one authenticated SSE connection.
|
||||||
|
// @Tags Admin Dashboard
|
||||||
|
// @Security AdminJWT
|
||||||
|
// @Produce text/event-stream
|
||||||
|
// @Success 200 {object} response.ApiResponse{data=admin.RpcStatsDashboardResponse}
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /admin/api/v1/dashboard/rpc-stats [get]
|
||||||
|
func (c *BaseAdminController) RpcStats(ctx echo.Context) error {
|
||||||
|
return c.streamRpcStats(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRpcStatsResponse() (RpcStatsDashboardResponse, error) {
|
||||||
|
chains, err := data.ListChains()
|
||||||
|
if err != nil {
|
||||||
|
return RpcStatsDashboardResponse{}, err
|
||||||
|
}
|
||||||
|
stats := data.SnapshotRpcRuntimeStats()
|
||||||
|
now := time.Now()
|
||||||
|
resp := RpcStatsDashboardResponse{
|
||||||
|
GeneratedAt: now.Format(time.RFC3339),
|
||||||
|
Chains: make([]RpcStatsChainResponse, 0, len(chains)),
|
||||||
|
}
|
||||||
|
for _, chain := range chains {
|
||||||
|
network := strings.ToLower(strings.TrimSpace(chain.Network))
|
||||||
|
stat := stats[network]
|
||||||
|
successRate := rpcStatsSuccessRate(stat.SuccessCount, stat.FailureCount)
|
||||||
|
hasData := stat.SuccessCount+stat.FailureCount > 0
|
||||||
|
status := rpcStatsStatus(chain.Enabled, stat.SuccessCount, stat.FailureCount)
|
||||||
|
lastSyncAt := ""
|
||||||
|
if !stat.LastSyncAt.IsZero() {
|
||||||
|
lastSyncAt = stat.LastSyncAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Summary.TotalChains++
|
||||||
|
if chain.Enabled {
|
||||||
|
resp.Summary.EnabledChains++
|
||||||
|
}
|
||||||
|
if hasData {
|
||||||
|
resp.Summary.ActiveChains++
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case "ok":
|
||||||
|
resp.Summary.OkChains++
|
||||||
|
case "warning":
|
||||||
|
resp.Summary.WarningChains++
|
||||||
|
case "down":
|
||||||
|
resp.Summary.DownChains++
|
||||||
|
case "disabled":
|
||||||
|
resp.Summary.DisabledChains++
|
||||||
|
default:
|
||||||
|
resp.Summary.UnknownChains++
|
||||||
|
}
|
||||||
|
resp.Summary.SuccessCount += stat.SuccessCount
|
||||||
|
resp.Summary.FailureCount += stat.FailureCount
|
||||||
|
|
||||||
|
resp.Chains = append(resp.Chains, RpcStatsChainResponse{
|
||||||
|
Network: chain.Network,
|
||||||
|
DisplayName: rpcStatsDisplayName(chain.Network, chain.DisplayName),
|
||||||
|
Enabled: chain.Enabled,
|
||||||
|
HasData: hasData,
|
||||||
|
Status: status,
|
||||||
|
SuccessCount: stat.SuccessCount,
|
||||||
|
FailureCount: stat.FailureCount,
|
||||||
|
SuccessRate: successRate,
|
||||||
|
LatestBlockHeight: stat.LatestBlockHeight,
|
||||||
|
LastSyncAt: lastSyncAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resp.Summary.SuccessRate = rpcStatsSuccessRate(resp.Summary.SuccessCount, resp.Summary.FailureCount)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BaseAdminController) streamRpcStats(ctx echo.Context) error {
|
||||||
|
initial, err := buildRpcStatsResponse()
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := ctx.Response()
|
||||||
|
resp.Header().Set(echo.HeaderContentType, "text/event-stream; charset=utf-8")
|
||||||
|
resp.Header().Set("Cache-Control", "no-cache")
|
||||||
|
resp.Header().Set("Connection", "keep-alive")
|
||||||
|
resp.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
flusher, ok := resp.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "streaming unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.WriteHeader(http.StatusOK)
|
||||||
|
if _, err := fmt.Fprint(resp.Writer, "retry: 5000\n\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeRpcStatsSSE(ctx, flusher, initial); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Request().Context().Done():
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
payload, err := buildRpcStatsResponse()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeRpcStatsSSE(ctx, flusher, payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRpcStatsSSE(ctx echo.Context, flusher http.Flusher, payload RpcStatsDashboardResponse) error {
|
||||||
|
envelope := rpcStatsStreamEnvelope{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Message: "success",
|
||||||
|
Data: payload,
|
||||||
|
RequestID: ctx.Request().Header.Get(echo.HeaderXRequestID),
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(envelope)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(ctx.Response().Writer, "event: rpc_stats\ndata: %s\n\n", b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcStatsDisplayName(network string, displayName string) string {
|
||||||
|
if name := strings.TrimSpace(displayName); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(network)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcStatsSuccessRate(successCount, failureCount int64) float64 {
|
||||||
|
total := successCount + failureCount
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(successCount) / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcStatsStatus(enabled bool, successCount, failureCount int64) string {
|
||||||
|
if !enabled {
|
||||||
|
return "disabled"
|
||||||
|
}
|
||||||
|
if successCount+failureCount == 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
if successCount == 0 {
|
||||||
|
return "down"
|
||||||
|
}
|
||||||
|
if failureCount > 0 {
|
||||||
|
return "warning"
|
||||||
|
}
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
// RecentOrders returns the last N orders for the dashboard list card.
|
// RecentOrders returns the last N orders for the dashboard list card.
|
||||||
// @Summary Recent orders
|
// @Summary Recent orders
|
||||||
// @Description Returns the last N orders for the dashboard
|
// @Description Returns the last N orders for the dashboard
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -10,6 +9,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/notify"
|
"github.com/GMWalletApp/epusdt/notify"
|
||||||
"github.com/GMWalletApp/epusdt/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ func (c *BaseAdminController) ListNotificationChannels(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error {
|
func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error {
|
||||||
req := new(CreateNotificationChannelRequest)
|
req := new(CreateNotificationChannelRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
req.Type = strings.ToLower(strings.TrimSpace(req.Type))
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
@@ -120,7 +120,7 @@ func (c *BaseAdminController) CreateNotificationChannel(ctx echo.Context) error
|
|||||||
func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error {
|
func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
existing, err := data.GetNotificationChannelByID(id)
|
existing, err := data.GetNotificationChannelByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -130,7 +130,7 @@ func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error
|
|||||||
|
|
||||||
req := new(UpdateNotificationChannelRequest)
|
req := new(UpdateNotificationChannelRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
if req.Name != nil {
|
if req.Name != nil {
|
||||||
@@ -175,7 +175,7 @@ func (c *BaseAdminController) UpdateNotificationChannel(ctx echo.Context) error
|
|||||||
func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error {
|
func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
existing, err := data.GetNotificationChannelByID(id)
|
existing, err := data.GetNotificationChannelByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -185,7 +185,7 @@ func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context)
|
|||||||
|
|
||||||
req := new(ChangeChannelStatusRequest)
|
req := new(ChangeChannelStatusRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
if err := data.UpdateNotificationChannelFields(id, map[string]interface{}{"enabled": req.Enabled}); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -209,7 +209,7 @@ func (c *BaseAdminController) ChangeNotificationChannelStatus(ctx echo.Context)
|
|||||||
func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error {
|
func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
existing, err := data.GetNotificationChannelByID(id)
|
existing, err := data.GetNotificationChannelByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -230,12 +230,12 @@ func (c *BaseAdminController) DeleteNotificationChannel(ctx echo.Context) error
|
|||||||
// + chat_id; webhook/email kept loose until those senders exist.
|
// + chat_id; webhook/email kept loose until those senders exist.
|
||||||
func validateChannelConfig(channelType string, cfg map[string]interface{}) error {
|
func validateChannelConfig(channelType string, cfg map[string]interface{}) error {
|
||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return errors.New("config required")
|
return constant.NotificationConfigErr
|
||||||
}
|
}
|
||||||
if channelType == mdb.NotificationTypeTelegram {
|
if channelType == mdb.NotificationTypeTelegram {
|
||||||
configJSON, _ := json.Marshal(cfg)
|
configJSON, _ := json.Marshal(cfg)
|
||||||
if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil {
|
if _, err := notify.ParseTelegramConfig(string(configJSON)); err != nil {
|
||||||
return err
|
return constant.NotificationConfigErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -260,10 +260,10 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
|||||||
case "1", "true", "on", "yes":
|
case "1", "true", "on", "yes":
|
||||||
return true, nil
|
return true, nil
|
||||||
default:
|
default:
|
||||||
return false, errors.New("invalid boolean string")
|
return false, constant.NotificationEventsErr
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
return false, errors.New("invalid event value type")
|
return false, constant.NotificationEventsErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +299,7 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
|||||||
for _, v := range t {
|
for _, v := range t {
|
||||||
s, ok := v.(string)
|
s, ok := v.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, errors.New("events array must contain strings")
|
return nil, constant.NotificationEventsErr
|
||||||
}
|
}
|
||||||
s = trimKey(s)
|
s = trimKey(s)
|
||||||
if s == "" {
|
if s == "" {
|
||||||
@@ -309,6 +309,6 @@ func normalizeChannelEvents(raw interface{}) (map[string]bool, error) {
|
|||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
default:
|
default:
|
||||||
return nil, errors.New("events must be object or string array")
|
return nil, constant.NotificationEventsErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package admin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -13,14 +12,11 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/GMWalletApp/epusdt/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MarkPaidRequest is the payload for manually marking an order as paid.
|
|
||||||
type MarkPaidRequest struct {
|
|
||||||
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
|
|
||||||
}
|
|
||||||
|
|
||||||
// OrderListResponse wraps the paginated order list.
|
// OrderListResponse wraps the paginated order list.
|
||||||
type OrderListResponse struct {
|
type OrderListResponse struct {
|
||||||
List []mdb.Orders `json:"list"`
|
List []mdb.Orders `json:"list"`
|
||||||
@@ -92,7 +88,7 @@ func (c *BaseAdminController) GetOrder(ctx echo.Context) error {
|
|||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if order.ID == 0 {
|
if order.ID == 0 {
|
||||||
return c.FailJson(ctx, errors.New("order not found"))
|
return c.FailJson(ctx, constant.OrderNotExists)
|
||||||
}
|
}
|
||||||
return c.SucJson(ctx, order)
|
return c.SucJson(ctx, order)
|
||||||
}
|
}
|
||||||
@@ -114,20 +110,28 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if order.ID == 0 {
|
if order.ID == 0 {
|
||||||
return c.FailJson(ctx, errors.New("order not found"))
|
return c.FailJson(ctx, constant.OrderNotExists)
|
||||||
}
|
}
|
||||||
if order.Status != mdb.StatusWaitPay {
|
if order.Status != mdb.StatusWaitPay && order.Status != mdb.StatusWaitSelect {
|
||||||
return c.FailJson(ctx, errors.New("order is not waiting payment"))
|
return c.FailJson(ctx, constant.OrderNotWaitPay)
|
||||||
}
|
}
|
||||||
ok, err := data.CloseOrderManually(tradeID)
|
ok, err := data.CloseOrderManually(tradeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.FailJson(ctx, errors.New("close failed (concurrent state change)"))
|
return c.FailJson(ctx, constant.OrderStatusConflict)
|
||||||
|
}
|
||||||
|
// Release only concrete on-chain locks. Placeholder orders and provider-only
|
||||||
|
// orders do not reserve a local wallet amount.
|
||||||
|
if order.Status == mdb.StatusWaitPay &&
|
||||||
|
(strings.TrimSpace(order.PayProvider) == "" || order.PayProvider == mdb.PaymentProviderOnChain) &&
|
||||||
|
strings.TrimSpace(order.Network) != "" &&
|
||||||
|
strings.TrimSpace(order.ReceiveAddress) != "" &&
|
||||||
|
strings.TrimSpace(order.Token) != "" &&
|
||||||
|
order.ActualAmount > 0 {
|
||||||
|
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
||||||
}
|
}
|
||||||
// Release the transaction lock so the amount slot becomes reusable.
|
|
||||||
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
|
||||||
return c.SucJson(ctx, nil)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,46 +140,37 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|||||||
// callback + notification fire normally.
|
// callback + notification fire normally.
|
||||||
// @Summary Mark order paid
|
// @Summary Mark order paid
|
||||||
// @Description Manually mark a waiting order as paid (operator补单)
|
// @Description Manually mark a waiting order as paid (operator补单)
|
||||||
|
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
|
||||||
|
// @Description Aptos accepts a transaction hash.
|
||||||
|
// @Description Aptos automatic scanning polls fullnode ledger-version transaction ranges with a runtime cursor.
|
||||||
// @Tags Admin Orders
|
// @Tags Admin Orders
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param trade_id path string true "Trade ID"
|
// @Param trade_id path string true "Trade ID"
|
||||||
// @Param request body admin.MarkPaidRequest true "Block transaction ID"
|
// @Param request body request.ManualPaymentRequest true "Block transaction ID"
|
||||||
// @Success 200 {object} response.ApiResponse
|
// @Success 200 {object} response.ApiResponse
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse
|
||||||
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
|
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
|
||||||
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
||||||
tradeID := ctx.Param("trade_id")
|
tradeID := ctx.Param("trade_id")
|
||||||
req := new(MarkPaidRequest)
|
adminUserID := currentAdminUserID(ctx)
|
||||||
|
req := new(request.ManualPaymentRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
log.Sugar.Warnf("[admin-order] mark-paid bind failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
|
req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId)
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
|
log.Sugar.Warnf("[admin-order] mark-paid validation failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
resp, err := service.SubmitManualPayment(tradeID, req.BlockTransactionId)
|
||||||
if err != nil {
|
|
||||||
return c.FailJson(ctx, err)
|
|
||||||
}
|
|
||||||
if order.ID == 0 {
|
|
||||||
return c.FailJson(ctx, errors.New("order not found"))
|
|
||||||
}
|
|
||||||
if order.Status != mdb.StatusWaitPay {
|
|
||||||
return c.FailJson(ctx, errors.New("order is not waiting payment"))
|
|
||||||
}
|
|
||||||
err = service.OrderProcessing(&request.OrderProcessingRequest{
|
|
||||||
ReceiveAddress: order.ReceiveAddress,
|
|
||||||
Currency: order.Currency,
|
|
||||||
Token: order.Token,
|
|
||||||
Network: order.Network,
|
|
||||||
Amount: order.ActualAmount,
|
|
||||||
TradeId: order.TradeId,
|
|
||||||
BlockTransactionId: req.BlockTransactionId,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[admin-order] mark-paid failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
log.Sugar.Infof("[admin-order] mark-paid success admin_user_id=%d trade_id=%s block_transaction_id=%s", adminUserID, tradeID, resp.BlockTransactionId)
|
||||||
return c.SucJson(ctx, nil)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,13 +188,38 @@ func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
|||||||
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
|
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
|
||||||
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
|
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
|
||||||
tradeID := ctx.Param("trade_id")
|
tradeID := ctx.Param("trade_id")
|
||||||
|
adminUserID := currentAdminUserID(ctx)
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback load failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if order.ID == 0 {
|
||||||
|
err = constant.OrderNotExists
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if order.Status != mdb.StatusPaySuccess {
|
||||||
|
err = constant.OrderCallbackNotApplicable
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s status=%d err=%v", adminUserID, tradeID, order.Status, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(order.NotifyUrl) == "" {
|
||||||
|
err = constant.OrderNotifyURLEmptyErr
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
ok, err := data.ReopenOrderCallback(tradeID)
|
ok, err := data.ReopenOrderCallback(tradeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback reopen failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.FailJson(ctx, errors.New("order is not paid (callback not applicable)"))
|
err = constant.OrderResendCallbackErr
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
log.Sugar.Infof("[admin-order] resend-callback queued admin_user_id=%d trade_id=%s", adminUserID, tradeID)
|
||||||
return c.SucJson(ctx, nil)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -380,7 +400,7 @@ func (c *BaseAdminController) GetOrderWithSub(ctx echo.Context) error {
|
|||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if order.ID == 0 {
|
if order.ID == 0 {
|
||||||
return c.FailJson(ctx, errors.New("order not found"))
|
return c.FailJson(ctx, constant.OrderNotExists)
|
||||||
}
|
}
|
||||||
subOrders, err := data.GetAllSubOrders(tradeID)
|
subOrders, err := data.GetAllSubOrders(tradeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/task"
|
"github.com/GMWalletApp/epusdt/task"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CreateRpcNodeRequest is the payload for creating an RPC node.
|
// CreateRpcNodeRequest is the payload for creating an RPC node.
|
||||||
type CreateRpcNodeRequest struct {
|
type CreateRpcNodeRequest struct {
|
||||||
Network string `json:"network" validate:"required" example:"tron"`
|
Network string `json:"network" validate:"required" example:"tron"`
|
||||||
Url string `json:"url" validate:"required" example:"https://api.trongrid.io"`
|
Url string `json:"url" validate:"required" example:"https://api.trongrid.io"`
|
||||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
// 连接类型 http=HTTP请求 ws=WebSocket长连接 lite=TON liteserver配置
|
||||||
Type string `json:"type" validate:"required|in:http,ws" enums:"http,ws" example:"http"`
|
Type string `json:"type" validate:"required|in:http,ws,lite" enums:"http,ws,lite" example:"http"`
|
||||||
Weight int `json:"weight" example:"1"`
|
Weight int `json:"weight" example:"1"`
|
||||||
ApiKey string `json:"api_key" example:""`
|
ApiKey string `json:"api_key" example:""`
|
||||||
Enabled *bool `json:"enabled" example:"true"`
|
Enabled *bool `json:"enabled" example:"true"`
|
||||||
|
// 用途 general=通用 manual_verify=补单专用 both=通用+补单
|
||||||
|
Purpose string `json:"purpose" enums:"general,manual_verify,both" example:"general"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateRpcNodeRequest is the payload for updating an RPC node.
|
// UpdateRpcNodeRequest is the payload for updating an RPC node.
|
||||||
@@ -28,6 +31,7 @@ type UpdateRpcNodeRequest struct {
|
|||||||
Weight *int `json:"weight" example:"1"`
|
Weight *int `json:"weight" example:"1"`
|
||||||
ApiKey *string `json:"api_key" example:"your-api-key"`
|
ApiKey *string `json:"api_key" example:"your-api-key"`
|
||||||
Enabled *bool `json:"enabled" example:"true"`
|
Enabled *bool `json:"enabled" example:"true"`
|
||||||
|
Purpose *string `json:"purpose" enums:"general,manual_verify,both" example:"manual_verify"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListRpcNodes returns rows optionally filtered by network.
|
// ListRpcNodes returns rows optionally filtered by network.
|
||||||
@@ -64,7 +68,7 @@ func (c *BaseAdminController) ListRpcNodes(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
||||||
req := new(CreateRpcNodeRequest)
|
req := new(CreateRpcNodeRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -77,13 +81,23 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
|||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
enabled = *req.Enabled
|
enabled = *req.Enabled
|
||||||
}
|
}
|
||||||
|
purpose, err := rpcNodePurposeFromRequest(req.Purpose)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
nodeURL := strings.TrimSpace(req.Url)
|
||||||
|
nodeType := strings.ToLower(strings.TrimSpace(req.Type))
|
||||||
|
if err := validateRpcNodeURLForType(nodeURL, nodeType); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
row := &mdb.RpcNode{
|
row := &mdb.RpcNode{
|
||||||
Network: strings.ToLower(strings.TrimSpace(req.Network)),
|
Network: strings.ToLower(strings.TrimSpace(req.Network)),
|
||||||
Url: strings.TrimSpace(req.Url),
|
Url: nodeURL,
|
||||||
Type: strings.ToLower(strings.TrimSpace(req.Type)),
|
Type: nodeType,
|
||||||
Weight: weight,
|
Weight: weight,
|
||||||
ApiKey: req.ApiKey,
|
ApiKey: req.ApiKey,
|
||||||
Enabled: enabled,
|
Enabled: enabled,
|
||||||
|
Purpose: purpose,
|
||||||
Status: mdb.RpcNodeStatusUnknown,
|
Status: mdb.RpcNodeStatusUnknown,
|
||||||
LastLatencyMs: -1,
|
LastLatencyMs: -1,
|
||||||
}
|
}
|
||||||
@@ -93,7 +107,7 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
|||||||
return c.SucJson(ctx, row)
|
return c.SucJson(ctx, row)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateRpcNode patches url/weight/api_key/enabled.
|
// UpdateRpcNode patches url/weight/api_key/enabled/purpose.
|
||||||
// @Summary Update RPC node
|
// @Summary Update RPC node
|
||||||
// @Description Patch RPC node fields
|
// @Description Patch RPC node fields
|
||||||
// @Tags Admin RPC Nodes
|
// @Tags Admin RPC Nodes
|
||||||
@@ -108,15 +122,26 @@ func (c *BaseAdminController) CreateRpcNode(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
req := new(UpdateRpcNodeRequest)
|
req := new(UpdateRpcNodeRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
if req.Url != nil {
|
if req.Url != nil {
|
||||||
fields["url"] = *req.Url
|
row, err := data.GetRpcNodeByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if row.ID == 0 {
|
||||||
|
return c.FailJson(ctx, constant.RpcNodeNotFoundErr)
|
||||||
|
}
|
||||||
|
nodeURL := strings.TrimSpace(*req.Url)
|
||||||
|
if err := validateRpcNodeURLForType(nodeURL, row.Type); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
fields["url"] = nodeURL
|
||||||
}
|
}
|
||||||
if req.Weight != nil {
|
if req.Weight != nil {
|
||||||
fields["weight"] = *req.Weight
|
fields["weight"] = *req.Weight
|
||||||
@@ -127,6 +152,13 @@ func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
|||||||
if req.Enabled != nil {
|
if req.Enabled != nil {
|
||||||
fields["enabled"] = *req.Enabled
|
fields["enabled"] = *req.Enabled
|
||||||
}
|
}
|
||||||
|
if req.Purpose != nil {
|
||||||
|
purpose, err := rpcNodePurposeFromRequest(*req.Purpose)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
fields["purpose"] = purpose
|
||||||
|
}
|
||||||
if err := data.UpdateRpcNodeFields(id, fields); err != nil {
|
if err := data.UpdateRpcNodeFields(id, fields); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
@@ -146,7 +178,7 @@ func (c *BaseAdminController) UpdateRpcNode(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := data.DeleteRpcNodeByID(id); err != nil {
|
if err := data.DeleteRpcNodeByID(id); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -155,9 +187,8 @@ func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HealthCheckRpcNode performs an on-demand probe and writes the result.
|
// HealthCheckRpcNode performs an on-demand probe and writes the result.
|
||||||
// For HTTP endpoints this is a GET to the URL with a short timeout; for
|
// For HTTP/WS/lite endpoints this is a TCP-level check against the configured
|
||||||
// WS we just attempt a TCP-level check via the same HTTP client (the
|
// URL host. TON lite rows usually point to a global.config.json HTTPS URL.
|
||||||
// handshake URL resolves the host identically).
|
|
||||||
// @Summary Health check RPC node
|
// @Summary Health check RPC node
|
||||||
// @Description Perform an on-demand health probe on an RPC node
|
// @Description Perform an on-demand health probe on an RPC node
|
||||||
// @Tags Admin RPC Nodes
|
// @Tags Admin RPC Nodes
|
||||||
@@ -170,14 +201,14 @@ func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
|
func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
row, err := data.GetRpcNodeByID(id)
|
row, err := data.GetRpcNodeByID(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if row.ID == 0 {
|
if row.ID == 0 {
|
||||||
return c.FailJson(ctx, errors.New("node not found"))
|
return c.FailJson(ctx, constant.RpcNodeNotFoundErr)
|
||||||
}
|
}
|
||||||
status, latency := task.ProbeNode(row.Url)
|
status, latency := task.ProbeNode(row.Url)
|
||||||
if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil {
|
if err := data.UpdateRpcNodeHealth(id, status, latency); err != nil {
|
||||||
@@ -188,3 +219,45 @@ func (c *BaseAdminController) HealthCheckRpcNode(ctx echo.Context) error {
|
|||||||
"last_latency_ms": latency,
|
"last_latency_ms": latency,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func rpcNodePurposeFromRequest(raw string) (string, error) {
|
||||||
|
purpose := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
if purpose == "" {
|
||||||
|
return mdb.RpcNodePurposeGeneral, nil
|
||||||
|
}
|
||||||
|
switch purpose {
|
||||||
|
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeManualVerify, mdb.RpcNodePurposeBoth:
|
||||||
|
return purpose, nil
|
||||||
|
default:
|
||||||
|
return "", constant.RpcNodePurposeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRpcNodeURLForType(rawURL string, nodeType string) error {
|
||||||
|
rawURL = strings.TrimSpace(rawURL)
|
||||||
|
nodeType = strings.ToLower(strings.TrimSpace(nodeType))
|
||||||
|
parsed, err := url.Parse(rawURL)
|
||||||
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
||||||
|
return constant.RpcNodeURLErr
|
||||||
|
}
|
||||||
|
scheme := strings.ToLower(parsed.Scheme)
|
||||||
|
switch nodeType {
|
||||||
|
case mdb.RpcNodeTypeHttp:
|
||||||
|
if scheme == "http" || scheme == "https" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return constant.RpcNodeHTTPURLErr
|
||||||
|
case mdb.RpcNodeTypeWs:
|
||||||
|
if scheme == "ws" || scheme == "wss" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return constant.RpcNodeWebSocketURLErr
|
||||||
|
case mdb.RpcNodeTypeLite:
|
||||||
|
if scheme == "http" || scheme == "https" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return constant.RpcNodeHTTPURLErr
|
||||||
|
default:
|
||||||
|
return constant.RpcNodeTypeErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
appLog "github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/security"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,30 +20,47 @@ import (
|
|||||||
// Supported groups and keys:
|
// Supported groups and keys:
|
||||||
//
|
//
|
||||||
// - group=rate:
|
// - group=rate:
|
||||||
// rate.forced_usdt_rate (float) — override USDT exchange rate (0 = use api)
|
// rate.forced_rate_list (json) — override rate map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase
|
||||||
// rate.api_url (string) — external rate API URL
|
// rate.api_url (string) — external rate API URL used when no positive forced rate exists
|
||||||
// rate.adjust_percent (float) — rate adjustment percentage
|
// rate.adjust_percent (float) — rate adjustment percentage
|
||||||
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
||||||
//
|
//
|
||||||
// - group=epay:
|
// - group=epay:
|
||||||
// epay.default_token (string) — token for EPAY orders, e.g. "usdt" (default)
|
// epay.default_token (string) — default token for EPAY submit.php, e.g. "usdt"; empty allows status=4 placeholders when request token/network are also absent
|
||||||
// epay.default_currency (string) — fiat currency for EPAY orders, e.g. "cny" (default)
|
// epay.default_currency (string) — default fiat currency for EPAY submit.php, e.g. "cny"; empty falls back to cny
|
||||||
// epay.default_network (string) — blockchain network for EPAY orders, e.g. "tron" (default)
|
// epay.default_network (string) — default network for EPAY submit.php, e.g. "tron" or "ton"; empty allows status=4 placeholders when request token/network are also absent
|
||||||
|
//
|
||||||
|
// - group=okpay:
|
||||||
|
// okpay.enabled (bool) — enable OkPay as a switch-network payment option
|
||||||
|
// okpay.shop_id (string) — OkPay merchant/shop identifier
|
||||||
|
// okpay.shop_token (string) — OkPay signing token
|
||||||
|
// okpay.api_url (string) — OkPay API base URL
|
||||||
|
// okpay.callback_url (string) — server callback URL used for OkPay notify
|
||||||
|
// okpay.return_url (string) — optional default browser return URL after payment
|
||||||
|
// okpay.timeout_seconds (int) — outbound OkPay API timeout in seconds
|
||||||
|
// okpay.allow_tokens (string) — comma-separated allowed tokens, e.g. "USDT,TRX"
|
||||||
//
|
//
|
||||||
// - group=brand:
|
// - group=brand:
|
||||||
// brand.site_name (string) — site display name
|
// brand.checkout_name (string) — cashier display name (preferred)
|
||||||
// brand.logo_url (string) — logo image URL
|
// brand.logo_url (string) — logo image URL
|
||||||
// brand.page_title (string) — payment page title
|
// brand.site_title (string) — payment page title / website title (preferred)
|
||||||
// brand.pay_success_text (string) — text shown on payment success
|
// brand.success_copy (string) — text shown on payment success (preferred)
|
||||||
// brand.support_url (string) — support / help URL
|
// brand.support_url (string) — support / help URL
|
||||||
|
// brand.background_color (string) — checkout background color
|
||||||
|
// brand.background_image_url (string) — checkout background image URL
|
||||||
|
// brand.site_name (string) — legacy cashier/site display name
|
||||||
|
// brand.page_title (string) — legacy payment page title
|
||||||
|
// brand.pay_success_text (string) — legacy success text
|
||||||
//
|
//
|
||||||
// - group=system:
|
// - group=system:
|
||||||
// system.order_expiration_time (int) — order expiry in minutes
|
// system.order_expiration_time (int) — order expiry in minutes
|
||||||
|
// system.amount_precision (int) — payment amount precision, 2-6 decimals (default 2)
|
||||||
|
// system.log_level (string) — runtime log level: debug, info, warn, error (default error)
|
||||||
type SettingUpsertItem struct {
|
type SettingUpsertItem struct {
|
||||||
Group string `json:"group" enums:"brand,rate,system,epay" example:"epay"`
|
Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"`
|
||||||
Key string `json:"key" example:"epay.default_network"`
|
Key string `json:"key" example:"epay.default_network"`
|
||||||
Value string `json:"value" example:"tron"`
|
Value interface{} `json:"value"`
|
||||||
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
|
Type string `json:"type" enums:"string,int,bool,json" example:"string"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingsUpsertRequest is the payload for batch upserting settings.
|
// SettingsUpsertRequest is the payload for batch upserting settings.
|
||||||
@@ -48,12 +71,12 @@ type SettingsUpsertRequest struct {
|
|||||||
// ListSettings returns all rows, optionally filtered by group.
|
// ListSettings returns all rows, optionally filtered by group.
|
||||||
// @Summary List settings
|
// @Summary List settings
|
||||||
// @Description Returns all settings, optionally filtered by group.
|
// @Description Returns all settings, optionally filtered by group.
|
||||||
// @Description Available groups: brand, rate, system, epay.
|
// @Description Available groups: brand, rate, system, epay, okpay.
|
||||||
// @Description See SettingUpsertItem for the full list of supported keys per group.
|
// @Description See SettingUpsertItem for the full list of supported keys per group.
|
||||||
// @Tags Admin Settings
|
// @Tags Admin Settings
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param group query string false "Group filter (brand|rate|system|epay)"
|
// @Param group query string false "Group filter (brand|rate|system|epay|okpay)"
|
||||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.Setting}
|
// @Success 200 {object} response.ApiResponse{data=[]mdb.Setting}
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse
|
||||||
// @Router /admin/api/v1/settings [get]
|
// @Router /admin/api/v1/settings [get]
|
||||||
@@ -70,12 +93,13 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
|||||||
// independently so a malformed row in the middle doesn't drop earlier
|
// independently so a malformed row in the middle doesn't drop earlier
|
||||||
// ones. Errors are returned per-key so the UI can surface them.
|
// ones. Errors are returned per-key so the UI can surface them.
|
||||||
// @Summary Upsert settings
|
// @Summary Upsert settings
|
||||||
// @Description Batch insert/update settings. Returns per-key status.
|
// @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
|
||||||
// @Description Supported groups: brand, rate, system, epay.
|
// @Description Supported groups: brand, rate, system, epay, okpay.
|
||||||
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
|
// @Description epay group keys: epay.default_token (e.g. "usdt" or "ton", 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 rate group keys: rate.forced_usdt_rate, rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
// @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 brand group keys: brand.site_name, brand.logo_url, brand.page_title, brand.pay_success_text, brand.support_url.
|
// @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 system group keys: system.order_expiration_time.
|
// @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported.
|
||||||
|
// @Description system group keys: system.order_expiration_time, system.amount_precision (int, 2-6, default 2), system.log_level (debug|info|warn|error, default error).
|
||||||
// @Tags Admin Settings
|
// @Tags Admin Settings
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -87,27 +111,60 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
||||||
req := new(SettingsUpsertRequest)
|
req := new(SettingsUpsertRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
type result struct {
|
type result struct {
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Error string `json:"error,omitempty"`
|
ErrorCode int `json:"error_code,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
out := make([]result, 0, len(req.Items))
|
out := make([]result, 0, len(req.Items))
|
||||||
|
errorResult := func(key string, err error) result {
|
||||||
|
code, msg := constant.ResolveErrno(err)
|
||||||
|
return result{Key: key, OK: false, ErrorCode: code, Error: msg}
|
||||||
|
}
|
||||||
for _, item := range req.Items {
|
for _, item := range req.Items {
|
||||||
key := strings.TrimSpace(item.Key)
|
key := strings.TrimSpace(item.Key)
|
||||||
if key == "" {
|
if key == "" {
|
||||||
out = append(out, result{Key: item.Key, OK: false, Error: "key required"})
|
out = append(out, errorResult(item.Key, constant.SettingItemErr))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := data.SetSetting(item.Group, key, item.Value, item.Type); err != nil {
|
value, err := normalizeSettingValue(item.Value)
|
||||||
out = append(out, result{Key: key, OK: false, Error: err.Error()})
|
if err != nil {
|
||||||
|
out = append(out, errorResult(key, constant.SettingItemErr))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
value, err = normalizeAndValidateSettingItem(item.Group, key, value)
|
||||||
|
if err != nil {
|
||||||
|
out = append(out, errorResult(key, constant.SettingItemErr))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key == mdb.SettingKeyAmountPrecision {
|
||||||
|
item.Group = mdb.SettingGroupSystem
|
||||||
|
item.Type = mdb.SettingTypeInt
|
||||||
|
}
|
||||||
|
if key == mdb.SettingKeyRateForcedRateList {
|
||||||
|
item.Group = mdb.SettingGroupRate
|
||||||
|
item.Type = mdb.SettingTypeJSON
|
||||||
|
}
|
||||||
|
if key == mdb.SettingKeySystemLogLevel {
|
||||||
|
item.Group = mdb.SettingGroupSystem
|
||||||
|
item.Type = mdb.SettingTypeString
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(item.Group, key, value, item.Type); err != nil {
|
||||||
|
out = append(out, errorResult(key, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if key == mdb.SettingKeySystemLogLevel {
|
||||||
|
if err := appLog.SetLevel(value); err != nil {
|
||||||
|
out = append(out, errorResult(key, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
out = append(out, result{Key: key, OK: true})
|
out = append(out, result{Key: key, OK: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +189,113 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
|||||||
return c.SucJson(ctx, out)
|
return c.SucJson(ctx, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeSettingValue(value interface{}) (string, error) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return "", nil
|
||||||
|
case string:
|
||||||
|
return v, nil
|
||||||
|
default:
|
||||||
|
b, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("value must be JSON serializable")
|
||||||
|
}
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAndValidateSettingItem(group, key, value string) (string, error) {
|
||||||
|
switch key {
|
||||||
|
case mdb.SettingKeyAmountPrecision:
|
||||||
|
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
|
||||||
|
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
|
||||||
|
}
|
||||||
|
precision, err := strconv.Atoi(strings.TrimSpace(value))
|
||||||
|
if err != nil {
|
||||||
|
return value, fmt.Errorf("%s must be an integer", key)
|
||||||
|
}
|
||||||
|
if precision < data.MinAmountPrecision || precision > data.MaxAmountPrecision {
|
||||||
|
return value, fmt.Errorf("%s must be between %d and %d", key, data.MinAmountPrecision, data.MaxAmountPrecision)
|
||||||
|
}
|
||||||
|
case mdb.SettingKeyRateForcedRateList:
|
||||||
|
normalized, err := normalizeForcedRateListSetting(group, key, value)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
case mdb.SettingKeyRateApiUrl:
|
||||||
|
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupRate {
|
||||||
|
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupRate)
|
||||||
|
}
|
||||||
|
if err := security.ValidatePublicHTTPURL(value); err != nil {
|
||||||
|
return value, fmt.Errorf("%s invalid: %w", key, err)
|
||||||
|
}
|
||||||
|
case mdb.SettingKeySystemLogLevel:
|
||||||
|
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
|
||||||
|
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
|
||||||
|
}
|
||||||
|
normalized, err := appLog.NormalizeLevel(value)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeForcedRateListSetting(group, key, value string) (string, error) {
|
||||||
|
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupRate {
|
||||||
|
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupRate)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rates map[string]map[string]float64
|
||||||
|
if err := json.Unmarshal([]byte(value), &rates); err != nil {
|
||||||
|
return value, fmt.Errorf("%s must be valid JSON object", key)
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized := make(map[string]map[string]float64, len(rates))
|
||||||
|
for base, coins := range rates {
|
||||||
|
normalizedBase := strings.ToLower(strings.TrimSpace(base))
|
||||||
|
if normalizedBase == "" {
|
||||||
|
return value, fmt.Errorf("%s base currency must not be empty", key)
|
||||||
|
}
|
||||||
|
if coins == nil {
|
||||||
|
return value, fmt.Errorf("%s.%s must be an object", key, base)
|
||||||
|
}
|
||||||
|
normalizedCoins, ok := normalized[normalizedBase]
|
||||||
|
if !ok {
|
||||||
|
normalizedCoins = make(map[string]float64, len(coins))
|
||||||
|
normalized[normalizedBase] = normalizedCoins
|
||||||
|
}
|
||||||
|
for coin, rate := range coins {
|
||||||
|
normalizedCoin := strings.ToLower(strings.TrimSpace(coin))
|
||||||
|
if normalizedCoin == "" {
|
||||||
|
return value, fmt.Errorf("%s coin must not be empty", key)
|
||||||
|
}
|
||||||
|
if rate < 0 {
|
||||||
|
return value, fmt.Errorf("%s.%s.%s must be >= 0", key, base, coin)
|
||||||
|
}
|
||||||
|
if _, exists := normalizedCoins[normalizedCoin]; exists {
|
||||||
|
return value, fmt.Errorf("%s.%s.%s is duplicated after normalization", key, normalizedBase, normalizedCoin)
|
||||||
|
}
|
||||||
|
normalizedCoins[normalizedCoin] = rate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizedValue, err := json.Marshal(normalized)
|
||||||
|
if err != nil {
|
||||||
|
return value, fmt.Errorf("%s must be JSON serializable", key)
|
||||||
|
}
|
||||||
|
return string(normalizedValue), nil
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteSetting removes one row. The next read of that key will fall
|
// DeleteSetting removes one row. The next read of that key will fall
|
||||||
// back to the hardcoded default (see settings_data.GetSetting*).
|
// back to the hardcoded default (see settings_data.GetSetting*).
|
||||||
// @Summary Delete setting
|
// @Summary Delete setting
|
||||||
// @Description Remove a setting by key (falls back to default)
|
// @Description Remove a setting by key (falls back to default). Deleting system.log_level immediately resets runtime logging to error.
|
||||||
// @Tags Admin Settings
|
// @Tags Admin Settings
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -151,9 +311,10 @@ func (c *BaseAdminController) DeleteSetting(ctx echo.Context) error {
|
|||||||
if err := data.DeleteSetting(key); err != nil {
|
if err := data.DeleteSetting(key); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
if key == mdb.SettingKeySystemLogLevel {
|
||||||
|
if err := appLog.SetLevel(mdb.SettingDefaultSystemLogLevel); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return c.SucJson(ctx, nil)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Public helper for the rate/usdt overrides — used by config package to
|
|
||||||
// read settings-backed values without importing the controller package.
|
|
||||||
var _ = mdb.SettingKeyRateForcedUsdt // ensure key constants remain referenced
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ func (c *BaseAdminController) AdminListWallets(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
|
func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
|
||||||
req := new(AdminAddWalletRequest)
|
req := new(AdminAddWalletRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -140,14 +140,14 @@ func (c *BaseAdminController) AdminAddWallet(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
|
func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
wallet, err := data.GetWalletAddressById(id)
|
wallet, err := data.GetWalletAddressById(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if wallet.ID == 0 {
|
if wallet.ID == 0 {
|
||||||
return c.FailJson(ctx, errors.New("wallet not found"))
|
return c.FailJson(ctx, constant.WalletNotFoundErr)
|
||||||
}
|
}
|
||||||
counts, _ := data.CountOrdersByAddress()
|
counts, _ := data.CountOrdersByAddress()
|
||||||
return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]})
|
return c.SucJson(ctx, WalletListItem{WalletAddress: *wallet, OrderCount: counts[wallet.Address]})
|
||||||
@@ -168,11 +168,11 @@ func (c *BaseAdminController) AdminGetWallet(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
|
func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
req := new(AdminUpdateWalletRequest)
|
req := new(AdminUpdateWalletRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
fields := map[string]interface{}{}
|
fields := map[string]interface{}{}
|
||||||
if req.Remark != nil {
|
if req.Remark != nil {
|
||||||
@@ -202,11 +202,11 @@ func (c *BaseAdminController) AdminUpdateWallet(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
|
func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
req := new(AdminChangeWalletStatusRequest)
|
req := new(AdminChangeWalletStatusRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -230,7 +230,7 @@ func (c *BaseAdminController) AdminChangeWalletStatus(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := data.DeleteWalletAddressById(id); err != nil {
|
if err := data.DeleteWalletAddressById(id); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
@@ -242,7 +242,7 @@ func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
|||||||
// them all as observation wallets. Per-row failures are collected and
|
// them all as observation wallets. Per-row failures are collected and
|
||||||
// returned so the UI can surface which addresses already existed.
|
// returned so the UI can surface which addresses already existed.
|
||||||
// @Summary Batch import wallets
|
// @Summary Batch import wallets
|
||||||
// @Description Import multiple wallet addresses at once. Per-row status is returned.
|
// @Description Import multiple wallet addresses at once. Per-row status is returned; failed rows include error_code for frontend i18n.
|
||||||
// @Tags Admin Wallets
|
// @Tags Admin Wallets
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -254,21 +254,23 @@ func (c *BaseAdminController) AdminDeleteWallet(ctx echo.Context) error {
|
|||||||
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
|
func (c *BaseAdminController) AdminBatchImportWallets(ctx echo.Context) error {
|
||||||
req := new(AdminBatchImportRequest)
|
req := new(AdminBatchImportRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
type result struct {
|
type result struct {
|
||||||
Address string `json:"address"`
|
Address string `json:"address"`
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Error string `json:"error,omitempty"`
|
ErrorCode int `json:"error_code,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
}
|
}
|
||||||
out := make([]result, 0, len(req.Addresses))
|
out := make([]result, 0, len(req.Addresses))
|
||||||
for _, addr := range req.Addresses {
|
for _, addr := range req.Addresses {
|
||||||
row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
|
row, err := data.AddWalletAddressWithNetwork(req.Network, addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out = append(out, result{Address: addr, OK: false, Error: err.Error()})
|
code, msg := constant.ResolveErrno(err)
|
||||||
|
out = append(out, result{Address: addr, OK: false, ErrorCode: code, Error: msg})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
|
_ = dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", row.ID).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/GMWalletApp/epusdt/util/http"
|
"github.com/GMWalletApp/epusdt/util/http"
|
||||||
"github.com/gookit/validate"
|
"github.com/gookit/validate"
|
||||||
"github.com/gookit/validate/locales/zhcn"
|
"github.com/gookit/validate/locales/zhcn"
|
||||||
@@ -46,6 +46,6 @@ func (c *BaseController) ValidateStruct(ctx echo.Context, i interface{}, scene .
|
|||||||
if v.Validate() {
|
if v.Validate() {
|
||||||
return nil
|
return nil
|
||||||
} else {
|
} else {
|
||||||
return errors.New(v.Errors.One())
|
return constant.ParamsMarshalErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package comm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OkPayNotify receives the server-to-server OkPay payment callback.
|
||||||
|
// The handler accepts JSON or form-urlencoded payloads and returns plain-text
|
||||||
|
// acknowledgements expected by the upstream provider.
|
||||||
|
// @Summary OkPay notify callback
|
||||||
|
// @Description Receives OkPay deposit callbacks for hosted checkout child orders.
|
||||||
|
// @Description The callback is verified against the configured OkPay shop token and, on success, marks the matching child order as paid.
|
||||||
|
// @Tags Payment
|
||||||
|
// @Accept json
|
||||||
|
// @Accept x-www-form-urlencoded
|
||||||
|
// @Produce plain
|
||||||
|
// @Success 200 {string} string "success"
|
||||||
|
// @Failure 400 {string} string "fail"
|
||||||
|
// @Router /payments/okpay/v1/notify [post]
|
||||||
|
func (c *BaseCommController) OkPayNotify(ctx echo.Context) error {
|
||||||
|
req := ctx.Request()
|
||||||
|
rawBody, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[okpay] read notify body failed err=%v", err)
|
||||||
|
return ctx.String(http.StatusBadRequest, "fail")
|
||||||
|
}
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(rawBody))
|
||||||
|
|
||||||
|
form := make(map[string]string)
|
||||||
|
copyForm := func(values url.Values) {
|
||||||
|
for key, items := range values {
|
||||||
|
if len(items) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
form[key] = items[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copyForm(req.URL.Query())
|
||||||
|
|
||||||
|
contentType := strings.ToLower(req.Header.Get(echo.HeaderContentType))
|
||||||
|
|
||||||
|
if strings.Contains(contentType, echo.MIMEApplicationJSON) && len(rawBody) > 0 {
|
||||||
|
if jsonForm, jsonErr := flattenOkPayJSONBody(rawBody); jsonErr == nil {
|
||||||
|
for key, value := range jsonForm {
|
||||||
|
form[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match the PHP example's $_POST semantics: let Echo/net/http parse both
|
||||||
|
// application/x-www-form-urlencoded and multipart/form-data.
|
||||||
|
if len(form) == 0 {
|
||||||
|
req.Body = io.NopCloser(bytes.NewReader(rawBody))
|
||||||
|
if parsedForm, formErr := ctx.FormParams(); formErr == nil {
|
||||||
|
copyForm(parsedForm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final fallback for raw query-string style bodies.
|
||||||
|
if len(form) == 0 && len(rawBody) > 0 && looksLikeQueryPayload(string(rawBody)) {
|
||||||
|
if parsed, parseErr := url.ParseQuery(string(rawBody)); parseErr == nil && len(parsed) > 0 {
|
||||||
|
copyForm(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rawFormData := string(rawBody)
|
||||||
|
if rawFormData == "" {
|
||||||
|
rawFormData = req.URL.RawQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(form) == 0 {
|
||||||
|
log.Sugar.Warnf("[okpay] empty notify payload method=%s content_type=%s remote=%s raw_query=%q raw_body=%q", req.Method, req.Header.Get(echo.HeaderContentType), ctx.RealIP(), req.URL.RawQuery, rawFormData)
|
||||||
|
return ctx.String(http.StatusBadRequest, "fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = service.HandleOkPayNotify(form, rawFormData); err != nil {
|
||||||
|
log.Sugar.Warnf("[okpay] notify handle failed method=%s content_type=%s remote=%s form=%v raw_query=%q raw_body=%q err=%v", req.Method, req.Header.Get(echo.HeaderContentType), ctx.RealIP(), form, req.URL.RawQuery, rawFormData, err)
|
||||||
|
return ctx.String(http.StatusBadRequest, "fail")
|
||||||
|
}
|
||||||
|
return ctx.String(http.StatusOK, "success")
|
||||||
|
}
|
||||||
|
|
||||||
|
func flattenOkPayJSONBody(rawBody []byte) (map[string]string, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(rawBody))
|
||||||
|
decoder.UseNumber()
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := decoder.Decode(&payload); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
flat := make(map[string]string)
|
||||||
|
for key, value := range payload {
|
||||||
|
if key == "data" {
|
||||||
|
dataMap, ok := value.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for dataKey, dataValue := range dataMap {
|
||||||
|
flat["data["+dataKey+"]"] = stringifyOkPayJSONValue(dataValue)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flat[key] = stringifyOkPayJSONValue(value)
|
||||||
|
}
|
||||||
|
return flat, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringifyOkPayJSONValue(value interface{}) string {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case nil:
|
||||||
|
return ""
|
||||||
|
case string:
|
||||||
|
return strings.TrimSpace(v)
|
||||||
|
case json.Number:
|
||||||
|
return v.String()
|
||||||
|
case float64:
|
||||||
|
if v == math.Trunc(v) {
|
||||||
|
return strconv.FormatInt(int64(v), 10)
|
||||||
|
}
|
||||||
|
return strconv.FormatFloat(v, 'f', -1, 64)
|
||||||
|
case bool:
|
||||||
|
if v {
|
||||||
|
return "true"
|
||||||
|
}
|
||||||
|
return "false"
|
||||||
|
default:
|
||||||
|
return strings.TrimSpace(fmt.Sprint(v))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeQueryPayload(raw string) bool {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(trimmed, "{") || strings.HasPrefix(trimmed, "[") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(trimmed, "=")
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
|
|||||||
// CreateTransaction 创建交易
|
// CreateTransaction 创建交易
|
||||||
// @Summary Create transaction
|
// @Summary Create transaction
|
||||||
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
|
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
|
||||||
|
// @Description GMPay may omit both token and network to create a status=4 placeholder order; EPay submit.php can also create one when neither request parameters nor database defaults provide token/network. Supplying only one of token/network is invalid.
|
||||||
|
// @Description payment_type is optional for GMPay. If it is sent, it is a normal signed parameter and must be included when calculating signature.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Accept x-www-form-urlencoded
|
// @Accept x-www-form-urlencoded
|
||||||
@@ -32,16 +34,16 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
|
|||||||
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
||||||
// @Param order_id formData string false "Merchant order ID"
|
// @Param order_id formData string false "Merchant order ID"
|
||||||
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
||||||
// @Param token formData string false "Crypto token (e.g. usdt)"
|
// @Param token formData string false "Crypto token (e.g. TON, USDT); omit together with network to create a placeholder where supported"
|
||||||
// @Param network formData string false "Network (e.g. tron)"
|
// @Param network formData string false "Network (e.g. ton, tron); omit together with token to create a placeholder where supported"
|
||||||
// @Param amount formData number false "Amount"
|
// @Param amount formData number false "Amount"
|
||||||
// @Param notify_url formData string false "Callback URL"
|
// @Param notify_url formData string false "Callback URL"
|
||||||
// @Param signature formData string false "MD5 signature"
|
// @Param signature formData string false "MD5 signature"
|
||||||
// @Param redirect_url formData string false "Redirect URL"
|
// @Param redirect_url formData string false "Redirect URL"
|
||||||
// @Param name formData string false "Order name"
|
// @Param name formData string false "Order name"
|
||||||
// @Param payment_type formData string false "Payment type"
|
// @Param payment_type formData string false "Optional GMPay compatibility flag; include in signature when sent"
|
||||||
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
|
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel"
|
||||||
// @Router /payments/gmpay/v1/order/create-transaction [post]
|
// @Router /payments/gmpay/v1/order/create-transaction [post]
|
||||||
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||||
req := new(request.CreateTransactionRequest)
|
req := new(request.CreateTransactionRequest)
|
||||||
@@ -58,15 +60,18 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
|||||||
return c.SucJson(ctx, resp)
|
return c.SucJson(ctx, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchNetwork 切换支付网络,创建或返回子订单
|
// SwitchNetwork 切换支付网络,补全占位父单或创建/返回子订单
|
||||||
// @Summary Switch payment network
|
// @Summary Switch payment network
|
||||||
// @Description Switch to a different payment network, creating or returning a sub-order
|
// @Description Switch to a different payment target. A status=4 placeholder is completed in place and returns the same parent trade_id with is_selected=false; an already concrete status=1 parent creates or returns the only sub-order when switching to a different target.
|
||||||
|
// @Description Normal values such as ton/tron/solana/ethereum select on-chain payment; the special value okpay selects OkPay hosted payment.
|
||||||
|
// @Description For status=4 placeholders from GMPay or EPay submit.php, both on-chain targets and okpay complete the parent in place without creating a child order. Sub-orders cannot be switched again.
|
||||||
|
// @Description For EPay orders with a merchant return_url, the returned redirect_url is the internal /pay/return/{trade_id} hop rather than the raw merchant return_url.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
|
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
|
||||||
// @Success 200 {object} response.ApiResponse
|
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10011 sub-order limit, 10012 cannot switch sub-order, 10013 not waiting payment, 10016 unsupported asset, 10017/10018/10019 provider errors, 10042 provider order creation failed"
|
||||||
// @Router /pay/switch-network [post]
|
// @Router /pay/switch-network [post]
|
||||||
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
||||||
req := new(request.SwitchNetworkRequest)
|
req := new(request.SwitchNetworkRequest)
|
||||||
@@ -83,7 +88,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
|||||||
|
|
||||||
jsonBytes, err := json.MarshalIndent(resp, "", " ")
|
jsonBytes, err := json.MarshalIndent(resp, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, constant.SystemErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes))
|
log.Sugar.Debugf("switch network response: \n%s", string(jsonBytes))
|
||||||
@@ -96,7 +101,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
|||||||
// POST (form) per the legacy EPAY protocol; swagger documents POST as
|
// POST (form) per the legacy EPAY protocol; swagger documents POST as
|
||||||
// the canonical form — the GET variant is identical save the transport.
|
// the canonical form — the GET variant is identical save the transport.
|
||||||
// @Summary Create transaction and redirect (EPAY compat)
|
// @Summary Create transaction and redirect (EPAY compat)
|
||||||
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid.
|
// @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.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Accept x-www-form-urlencoded
|
// @Accept x-www-form-urlencoded
|
||||||
// @Produce html
|
// @Produce html
|
||||||
@@ -119,7 +124,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
|||||||
// @Param sign formData string true "MD5 signature"
|
// @Param sign formData string true "MD5 signature"
|
||||||
// @Param sign_type formData string false "Signature type (MD5)"
|
// @Param sign_type formData string false "Signature type (MD5)"
|
||||||
// @Success 302 "Redirect to checkout counter"
|
// @Success 302 "Redirect to checkout counter"
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel"
|
||||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
|
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
|
||||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
|
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
|
||||||
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
|
||||||
|
|||||||
@@ -1,57 +1,68 @@
|
|||||||
package comm
|
package comm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/config"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/GMWalletApp/epusdt/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
"github.com/GMWalletApp/epusdt/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CheckoutCounter 收银台
|
// CheckoutCounter 收银台
|
||||||
// @Summary Checkout counter page
|
// @Summary Checkout counter page
|
||||||
// @Description Render the payment checkout counter HTML page for a given trade
|
// @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 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 html
|
// @Produce json
|
||||||
// @Param trade_id path string true "Trade ID"
|
// @Param trade_id path string true "Trade ID"
|
||||||
// @Success 200 {string} string "HTML page"
|
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
||||||
// @Router /pay/checkout-counter/{trade_id} [get]
|
// @Failure 400 {object} response.ApiResponse "Order not found (status_code=10008) or other request error"
|
||||||
|
// @Router /pay/checkout-counter-resp/{trade_id} [get]
|
||||||
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
||||||
tradeId := ctx.Param("trade_id")
|
tradeId := ctx.Param("trade_id")
|
||||||
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
|
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrOrder {
|
return c.FailJson(ctx, err)
|
||||||
// Unknown trade id: render the page with empty payload
|
|
||||||
// (client side shows a friendly "order not found" screen).
|
|
||||||
tmpl, tmplErr := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
|
|
||||||
if tmplErr != nil {
|
|
||||||
return ctx.String(http.StatusInternalServerError, tmplErr.Error())
|
|
||||||
}
|
|
||||||
ctx.Response().Status = http.StatusNotFound
|
|
||||||
emptyResp := response.CheckoutCounterResponse{}
|
|
||||||
return tmpl.Execute(ctx.Response(), emptyResp)
|
|
||||||
}
|
|
||||||
return ctx.String(http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
|
||||||
tmpl, err := template.ParseFiles(filepath.Join(config.StaticFilePath, "index.html"))
|
|
||||||
if err != nil {
|
|
||||||
return ctx.String(http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return tmpl.Execute(ctx.Response(), resp)
|
return c.SucJson(ctx, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReturnToMerchant performs the browser-facing EPay success return hop.
|
||||||
|
// Non-EPay or not-yet-paid orders are sent back to the checkout counter.
|
||||||
|
// @Summary Return to merchant (EPAY compat)
|
||||||
|
// @Description Browser-facing success return hop for EPay orders. Paid EPay orders are redirected to the merchant return_url with signed legacy EPay query params. Orders that are not EPay or not yet paid are redirected back to the checkout counter.
|
||||||
|
// @Description This route also returns explicit business errors when the merchant return_url is missing, the order API key is unavailable, or EPay signature construction fails.
|
||||||
|
// @Tags Payment
|
||||||
|
// @Produce html
|
||||||
|
// @Param trade_id path string true "Trade ID"
|
||||||
|
// @Success 302 "Redirect to merchant return_url or checkout counter"
|
||||||
|
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10044 invalid order redirect url, 10045 order api key unavailable, 10046 failed to build epay return signature"
|
||||||
|
// @Router /pay/return/{trade_id} [get]
|
||||||
|
func (c *BaseCommController) ReturnToMerchant(ctx echo.Context) error {
|
||||||
|
tradeID := ctx.Param("trade_id")
|
||||||
|
redirect, err := service.ResolveEPayReturnRedirect(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if redirect.IsMerchantRedirect {
|
||||||
|
ctx.Response().Header().Set("Cache-Control", "no-store")
|
||||||
|
}
|
||||||
|
return ctx.Redirect(http.StatusFound, redirect.TargetURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckStatus 支付状态检测
|
// CheckStatus 支付状态检测
|
||||||
// @Summary Check payment status
|
// @Summary Check payment status
|
||||||
// @Description Check the payment status of an order by trade ID
|
// @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param trade_id path string true "Trade ID"
|
// @Param trade_id path string true "Trade ID"
|
||||||
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
|
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse "Order not found (status_code=10008) or other request error"
|
||||||
// @Router /pay/check-status/{trade_id} [get]
|
// @Router /pay/check-status/{trade_id} [get]
|
||||||
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
||||||
tradeId := ctx.Param("trade_id")
|
tradeId := ctx.Param("trade_id")
|
||||||
@@ -65,3 +76,35 @@ func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
|||||||
}
|
}
|
||||||
return c.SucJson(ctx, resp)
|
return c.SucJson(ctx, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubmitTxHash 手动提交交易 hash 补单
|
||||||
|
// @Summary Submit transaction hash
|
||||||
|
// @Description Submit an on-chain transaction hash from the cashier. The hash is verified against the order and, when valid, the same manual payment processing path used by admin mark-paid is executed. OkPay/provider orders are not supported.
|
||||||
|
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
|
||||||
|
// @Description Aptos accepts a transaction hash.
|
||||||
|
// @Description Aptos automatic scanning polls fullnode ledger-version transaction ranges with a runtime cursor.
|
||||||
|
// @Tags Payment
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param trade_id path string true "Trade ID"
|
||||||
|
// @Param request body request.ManualPaymentRequest true "Transaction hash payload"
|
||||||
|
// @Success 200 {object} response.ApiResponse{data=response.ManualPaymentResponse}
|
||||||
|
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10009 invalid params, 10013 not waiting payment, 10038 verification failed, 10039 unsupported provider, 10007 hash already processed"
|
||||||
|
// @Router /pay/submit-tx-hash/{trade_id} [post]
|
||||||
|
func (c *BaseCommController) SubmitTxHash(ctx echo.Context) (err error) {
|
||||||
|
tradeId := ctx.Param("trade_id")
|
||||||
|
req := new(request.ManualPaymentRequest)
|
||||||
|
if err = ctx.Bind(req); err != nil {
|
||||||
|
return c.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
|
}
|
||||||
|
req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId)
|
||||||
|
if err = c.ValidateStruct(ctx, req); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := service.SubmitCashierManualPayment(tradeId, req.BlockTransactionId)
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
return c.SucJson(ctx, resp)
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,38 +5,26 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
|
"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/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"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetSupportedAssets 对外公开可用链与 token 列表。数据源是管理后台
|
func buildSupportedAssets() ([]response.NetworkTokenSupport, error) {
|
||||||
// 的 chains + chain_tokens + wallet_address 三张表的实时查询,admin
|
|
||||||
// 启用/禁用链或代币后,这个接口在下次调用时就会反映新状态(无缓存)。
|
|
||||||
// 仅返回:链 enabled=true、代币 enabled=true、且该链上至少有一个可用
|
|
||||||
// 钱包地址的组合。
|
|
||||||
// @Summary List supported (network, tokens) groupings
|
|
||||||
// @Description Public endpoint, Auth required in /admin/api/v1/supported-assets
|
|
||||||
// @Tags Supported Assets
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} response.ApiResponse{data=response.SupportedAssetsResponse}
|
|
||||||
// @Failure 400 {object} response.ApiResponse
|
|
||||||
// @Router /payments/gmpay/v1/supported-assets [get]
|
|
||||||
// @Router /admin/api/v1/supported-assets [get]
|
|
||||||
func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
|
|
||||||
chains, err := data.ListEnabledChains()
|
chains, err := data.ListEnabledChains()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
wallets, err := data.GetAvailableWalletAddress()
|
wallets, err := data.GetAvailableWalletAddress()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// A chain only qualifies if it has at least one wallet available —
|
|
||||||
// without a recipient address, payments on that chain can't be
|
|
||||||
// processed even if the chain + token config says "enabled".
|
|
||||||
networkHasWallet := make(map[string]struct{})
|
networkHasWallet := make(map[string]struct{})
|
||||||
for _, w := range wallets {
|
for _, w := range wallets {
|
||||||
networkHasWallet[strings.ToLower(w.Network)] = struct{}{}
|
networkHasWallet[strings.ToLower(w.Network)] = struct{}{}
|
||||||
@@ -50,13 +38,16 @@ func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
|
|||||||
}
|
}
|
||||||
tokens, err := data.ListEnabledChainTokensByNetwork(network)
|
tokens, err := data.ListEnabledChainTokensByNetwork(network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(tokens) == 0 {
|
if len(tokens) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
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
|
||||||
@@ -67,24 +58,72 @@ func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sort.Strings(symbols)
|
sort.Strings(symbols)
|
||||||
|
displayName := strings.TrimSpace(ch.DisplayName)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = network
|
||||||
|
}
|
||||||
supports = append(supports, response.NetworkTokenSupport{
|
supports = append(supports, response.NetworkTokenSupport{
|
||||||
Network: network,
|
Network: network,
|
||||||
Tokens: symbols,
|
DisplayName: displayName,
|
||||||
|
Tokens: symbols,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.SucJson(ctx, response.SupportedAssetsResponse{Supports: supports})
|
return supports, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListSupportedAssetRecords 查询支持项明细(无需鉴权)。
|
// GetPublicConfig returns payment/site config consumed by cashier/frontends.
|
||||||
// @Summary List supported asset records
|
// The public route returns brand/site display config plus non-sensitive
|
||||||
// @Description Public endpoint. Returns enabled chain_tokens, optionally filtered by network.
|
// epay/okpay knobs, while the authenticated admin route also includes OkPay
|
||||||
// @Tags Supported Assets
|
// credentials and internal URLs.
|
||||||
|
// @Summary Get public payment config
|
||||||
|
// @Description Returns supported_assets plus site, epay and okpay config used by cashier/frontends.
|
||||||
|
// @Description The authenticated admin variant (/admin/api/v1/config) also includes OkPay shop credentials and internal callback settings.
|
||||||
|
// @Tags Payment Config
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param network query string false "Network filter (e.g. tron, ethereum)"
|
// @Success 200 {object} response.ApiResponse{data=response.PublicConfigResponse}
|
||||||
// @Success 200 {object} response.ApiResponse{data=[]mdb.ChainToken}
|
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse
|
||||||
// @Router /payments/gmpay/v1/supported-assets/records [get]
|
// @Router /payments/gmpay/v1/config [get]
|
||||||
|
// @Router /admin/api/v1/config [get]
|
||||||
|
func (c *BaseCommController) GetPublicConfig(ctx echo.Context) error {
|
||||||
|
supports, err := buildSupportedAssets()
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
okpay := response.OkPayPublicConfig{
|
||||||
|
Enabled: data.GetOkPayEnabled(),
|
||||||
|
AllowTokens: data.GetOkPayAllowTokens(),
|
||||||
|
}
|
||||||
|
if ctx.Get(middleware.AdminUserIDKey) != nil {
|
||||||
|
okpay.ShopID = data.GetOkPayShopID()
|
||||||
|
okpay.ShopToken = data.GetOkPayShopToken()
|
||||||
|
okpay.APIURL = data.GetOkPayAPIURL()
|
||||||
|
okpay.CallbackURL = data.GetOkPayCallbackURL()
|
||||||
|
okpay.ReturnURL = data.GetOkPayReturnURL()
|
||||||
|
okpay.TimeoutSeconds = data.GetOkPayTimeoutSeconds()
|
||||||
|
}
|
||||||
|
return c.SucJson(ctx, response.PublicConfigResponse{
|
||||||
|
SupportedAssets: supports,
|
||||||
|
Site: response.SitePublicConfig{
|
||||||
|
CashierName: data.GetBrandCashierName(),
|
||||||
|
LogoURL: data.GetBrandLogoURL(),
|
||||||
|
WebsiteTitle: data.GetBrandWebsiteTitle(),
|
||||||
|
SupportLink: data.GetBrandSupportURL(),
|
||||||
|
BackgroundColor: data.GetBrandBackgroundColor(),
|
||||||
|
BackgroundImageURL: data.GetBrandBackgroundImageURL(),
|
||||||
|
},
|
||||||
|
Epay: response.EpayPublicConfig{
|
||||||
|
DefaultToken: data.GetSettingString(mdb.SettingKeyEpayDefaultToken, ""),
|
||||||
|
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
||||||
|
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, ""),
|
||||||
|
},
|
||||||
|
OkPay: okpay,
|
||||||
|
Version: config.GetAppVersion(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSupportedAssetRecords is currently not wired to a public route.
|
||||||
|
// Kept as an internal helper candidate for future admin/debug use.
|
||||||
func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
|
func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
|
||||||
network := ctx.QueryParam("network")
|
network := ctx.QueryParam("network")
|
||||||
list, err := data.ListEnabledChainTokens(network)
|
list, err := data.ListEnabledChainTokens(network)
|
||||||
@@ -94,15 +133,8 @@ func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error {
|
|||||||
return c.SucJson(ctx, list)
|
return c.SucJson(ctx, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSupportedAsset 查询单条支持项(无需鉴权)。
|
// GetSupportedAsset is currently not wired to a public route.
|
||||||
// @Summary Get a supported asset
|
// Kept as an internal helper candidate for future admin/debug use.
|
||||||
// @Description Public endpoint. Returns one chain_token row by ID. Returns 404 if the token is disabled.
|
|
||||||
// @Tags Supported Assets
|
|
||||||
// @Produce json
|
|
||||||
// @Param id path int true "ChainToken ID"
|
|
||||||
// @Success 200 {object} response.ApiResponse{data=mdb.ChainToken}
|
|
||||||
// @Failure 400 {object} response.ApiResponse
|
|
||||||
// @Router /payments/gmpay/v1/supported-assets/{id} [get]
|
|
||||||
func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error {
|
func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error {
|
||||||
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package comm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildSupportedAssetsIncludesConfiguredAptosTokens(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, DisplayName: "Aptos"}).Error; err != nil {
|
||||||
|
t.Fatalf("create aptos chain: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkAptos, "0x1"); err != nil {
|
||||||
|
t.Fatalf("add aptos wallet: %v", err)
|
||||||
|
}
|
||||||
|
for _, row := range []mdb.ChainToken{
|
||||||
|
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", Decimals: 6, Enabled: true},
|
||||||
|
{Network: mdb.NetworkAptos, Symbol: "MOVEUSD", ContractAddress: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Decimals: 6, Enabled: true},
|
||||||
|
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: "", Decimals: 6, Enabled: true},
|
||||||
|
} {
|
||||||
|
if err := dao.Mdb.Create(&row).Error; err != nil {
|
||||||
|
t.Fatalf("create token %s: %v", row.Symbol, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
supports, err := buildSupportedAssets()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildSupportedAssets: %v", err)
|
||||||
|
}
|
||||||
|
if len(supports) != 1 {
|
||||||
|
t.Fatalf("supported assets len = %d, want 1: %#v", len(supports), supports)
|
||||||
|
}
|
||||||
|
if supports[0].Network != mdb.NetworkAptos {
|
||||||
|
t.Fatalf("network = %q, want %q", supports[0].Network, mdb.NetworkAptos)
|
||||||
|
}
|
||||||
|
want := []string{"MOVEUSD", "USDT"}
|
||||||
|
if len(supports[0].Tokens) != len(want) {
|
||||||
|
t.Fatalf("tokens = %#v, want %#v", supports[0].Tokens, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if supports[0].Tokens[i] != want[i] {
|
||||||
|
t.Fatalf("tokens = %#v, want %#v", supports[0].Tokens, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
package crypto
|
package crypto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/shengdoushi/base58"
|
"github.com/shengdoushi/base58"
|
||||||
)
|
)
|
||||||
@@ -28,3 +30,24 @@ func EncodeCheck(input []byte) string {
|
|||||||
|
|
||||||
return Encode(inputCheck)
|
return Encode(inputCheck)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DecodeCheck decodes a Base58Check string and validates its 4-byte checksum.
|
||||||
|
func DecodeCheck(input string) ([]byte, error) {
|
||||||
|
raw, err := base58.Decode(input, base58.BitcoinAlphabet)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(raw) < 4 {
|
||||||
|
return nil, fmt.Errorf("base58check payload too short")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := raw[:len(raw)-4]
|
||||||
|
checksum := raw[len(raw)-4:]
|
||||||
|
|
||||||
|
h0 := sha256.Sum256(payload)
|
||||||
|
h1 := sha256.Sum256(h0[:])
|
||||||
|
if !bytes.Equal(checksum, h1[:4]) {
|
||||||
|
return nil, fmt.Errorf("base58check checksum mismatch")
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ require (
|
|||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.20.1
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
|
github.com/xssnick/tonutils-go v1.16.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
gopkg.in/telebot.v3 v3.0.0
|
gopkg.in/telebot.v3 v3.0.0
|
||||||
|
|||||||
@@ -348,6 +348,8 @@ github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHg
|
|||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
|
github.com/xssnick/tonutils-go v1.16.0 h1:MLYVZiiMB0Q+XudLo7WyjOli4gMuenNmJpBqETqa5DE=
|
||||||
|
github.com/xssnick/tonutils-go v1.16.0/go.mod h1:CNSM+L0FYufOFhprg8otc9Y/ecFfLrWqFMq00Gh9yk4=
|
||||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||||
|
|||||||
+55
-31
@@ -79,6 +79,60 @@ type installHandler struct {
|
|||||||
done chan struct{}
|
done chan struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func installRootRedirectMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
|
||||||
|
return func(c echo.Context) error {
|
||||||
|
if c.Request().Method == http.MethodGet && c.Request().URL.Path == "/" {
|
||||||
|
return c.Redirect(http.StatusFound, "/install")
|
||||||
|
}
|
||||||
|
return next(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveInstallWWWRoot() string {
|
||||||
|
// Resolve www/ relative to the executable so SPA routes work regardless
|
||||||
|
// of the working directory. main.go extracts www/ next to the binary.
|
||||||
|
wwwRoot := "./www"
|
||||||
|
if exePath, err := os.Executable(); err == nil {
|
||||||
|
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
|
||||||
|
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return wwwRoot
|
||||||
|
}
|
||||||
|
|
||||||
|
func newInstallServer(envFilePath, wwwRoot string) (*echo.Echo, *installHandler) {
|
||||||
|
h := &installHandler{
|
||||||
|
envFilePath: envFilePath,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
e := echo.New()
|
||||||
|
e.HideBanner = true
|
||||||
|
e.HidePort = true
|
||||||
|
|
||||||
|
// api routes for the install frontend
|
||||||
|
api := e.Group("/api")
|
||||||
|
|
||||||
|
api.GET("/install/defaults", h.GetDefaults)
|
||||||
|
api.POST("/install", h.Submit)
|
||||||
|
|
||||||
|
// Redirect browser visits on root to /install so first-run users land
|
||||||
|
// on the wizard directly. This must run before the static middleware,
|
||||||
|
// otherwise "/" is intercepted by the SPA index.html fallback.
|
||||||
|
e.Use(installRootRedirectMiddleware)
|
||||||
|
|
||||||
|
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||||
|
Skipper: func(c echo.Context) bool {
|
||||||
|
return luluHttp.ShouldSkipSPAFallback(c.Request().URL.Path)
|
||||||
|
},
|
||||||
|
HTML5: true,
|
||||||
|
Index: "index.html",
|
||||||
|
Root: wwwRoot,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return e, h
|
||||||
|
}
|
||||||
|
|
||||||
// GetDefaults returns default values for the install form.
|
// GetDefaults returns default values for the install form.
|
||||||
//
|
//
|
||||||
// @Summary Install — get default values
|
// @Summary Install — get default values
|
||||||
@@ -172,37 +226,7 @@ func RunInstallServer(listenAddr, envFilePath string) {
|
|||||||
listenAddr = DefaultInstallAddr
|
listenAddr = DefaultInstallAddr
|
||||||
}
|
}
|
||||||
|
|
||||||
h := &installHandler{
|
e, h := newInstallServer(envFilePath, resolveInstallWWWRoot())
|
||||||
envFilePath: envFilePath,
|
|
||||||
done: make(chan struct{}),
|
|
||||||
}
|
|
||||||
|
|
||||||
e := echo.New()
|
|
||||||
e.HideBanner = true
|
|
||||||
e.HidePort = true
|
|
||||||
|
|
||||||
// api routes for the install frontend
|
|
||||||
api := e.Group("/api")
|
|
||||||
|
|
||||||
api.GET("/install/defaults", h.GetDefaults)
|
|
||||||
api.POST("/install", h.Submit)
|
|
||||||
|
|
||||||
// Resolve www/ relative to the executable so SPA routes work regardless
|
|
||||||
// of the working directory. main.go extracts www/ next to the binary.
|
|
||||||
wwwRoot := "./www"
|
|
||||||
if exePath, err := os.Executable(); err == nil {
|
|
||||||
if exePath, err = filepath.EvalSymlinks(exePath); err == nil {
|
|
||||||
wwwRoot = filepath.Join(filepath.Dir(exePath), "www")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
|
||||||
Skipper: func(c echo.Context) bool {
|
|
||||||
return luluHttp.ShouldSkipSPAFallback(c.Request().URL.Path)
|
|
||||||
},
|
|
||||||
HTML5: true,
|
|
||||||
Index: "index.html",
|
|
||||||
Root: wwwRoot,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Build a human-readable URL for the console hint.
|
// Build a human-readable URL for the console hint.
|
||||||
installHost := listenAddr
|
installHost := listenAddr
|
||||||
|
|||||||
@@ -95,6 +95,55 @@ func TestInstallAPIDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInstallServerRootRedirectsToInstall(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
wwwRoot := filepath.Join(dir, "www")
|
||||||
|
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir www root: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte("install-ui"), 0o644); err != nil {
|
||||||
|
t.Fatalf("write index.html: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status = %d, want 302; body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Location"); got != "/install" {
|
||||||
|
t.Fatalf("Location = %q, want /install", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstallServerServesSPAOnInstallRoute(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
wwwRoot := filepath.Join(dir, "www")
|
||||||
|
if err := os.MkdirAll(wwwRoot, 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir www root: %v", err)
|
||||||
|
}
|
||||||
|
const wantBody = "install-ui"
|
||||||
|
if err := os.WriteFile(filepath.Join(wwwRoot, "index.html"), []byte(wantBody), 0o644); err != nil {
|
||||||
|
t.Fatalf("write index.html: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
e, _ := newInstallServer(filepath.Join(dir, ".env"), wwwRoot)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/install", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200; body: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
if body := rec.Body.String(); body != wantBody {
|
||||||
|
t.Fatalf("body = %q, want %q", body, wantBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestInstallAPISubmit(t *testing.T) {
|
func TestInstallAPISubmit(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
envPath := filepath.Join(dir, ".env")
|
envPath := filepath.Join(dir, ".env")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
"gorm.io/gorm/schema"
|
"gorm.io/gorm/schema"
|
||||||
)
|
)
|
||||||
@@ -20,7 +21,6 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
viper.Set("forced_usdt_rate", 1.0)
|
|
||||||
viper.Set("app_uri", "https://example.com")
|
viper.Set("app_uri", "https://example.com")
|
||||||
viper.Set("order_expiration_time", 10)
|
viper.Set("order_expiration_time", 10)
|
||||||
viper.Set("order_notice_max_retry", 2)
|
viper.Set("order_notice_max_retry", 2)
|
||||||
@@ -31,7 +31,6 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
config.HTTPAccessLog = false
|
config.HTTPAccessLog = false
|
||||||
config.SQLDebug = false
|
config.SQLDebug = false
|
||||||
config.LogLevel = "error"
|
config.LogLevel = "error"
|
||||||
config.UsdtRate = 0
|
|
||||||
appLog.Sugar = zap.NewNop().Sugar()
|
appLog.Sugar = zap.NewNop().Sugar()
|
||||||
|
|
||||||
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
|
mainDB := mustOpenSQLite(t, filepath.Join(t.TempDir(), "main.db"))
|
||||||
@@ -39,6 +38,7 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
|
|
||||||
mustMigrate(t, mainDB,
|
mustMigrate(t, mainDB,
|
||||||
&mdb.Orders{},
|
&mdb.Orders{},
|
||||||
|
&mdb.ProviderOrder{},
|
||||||
&mdb.WalletAddress{},
|
&mdb.WalletAddress{},
|
||||||
&mdb.ApiKey{},
|
&mdb.ApiKey{},
|
||||||
&mdb.Setting{},
|
&mdb.Setting{},
|
||||||
@@ -52,15 +52,33 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
|
|
||||||
dao.Mdb = mainDB
|
dao.Mdb = mainDB
|
||||||
dao.RuntimeDB = runtimeDB
|
dao.RuntimeDB = runtimeDB
|
||||||
|
config.SettingsGetString = func(key string) string {
|
||||||
|
if dao.Mdb == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var row mdb.Setting
|
||||||
|
if err := dao.Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return row.Value
|
||||||
|
}
|
||||||
|
|
||||||
// Seed all standard chains as enabled so IsChainEnabled checks pass.
|
// Seed all standard chains as enabled so IsChainEnabled checks pass.
|
||||||
for _, network := range []string{
|
for _, network := range []string{
|
||||||
mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum,
|
mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum,
|
||||||
mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma,
|
mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkTon,
|
||||||
} {
|
} {
|
||||||
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
|
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainDB.Clauses(clause.OnConflict{DoNothing: true}).Create(&[]mdb.ChainToken{
|
||||||
|
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
|
||||||
|
{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true},
|
||||||
|
{Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true},
|
||||||
|
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||||
|
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true},
|
||||||
|
})
|
||||||
|
|
||||||
// Seed two universal api_keys rows. Both usable for EPAY/GMPAY
|
// Seed two universal api_keys rows. Both usable for EPAY/GMPAY
|
||||||
// flows; the numeric PID 1001 row lets legacy tests that submit
|
// flows; the numeric PID 1001 row lets legacy tests that submit
|
||||||
// `pid=1001` still match.
|
// `pid=1001` still match.
|
||||||
@@ -74,12 +92,21 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
Pid: "1001", SecretKey: "test-token",
|
Pid: "1001", SecretKey: "test-token",
|
||||||
Status: mdb.ApiKeyStatusEnable,
|
Status: mdb.ApiKeyStatusEnable,
|
||||||
})
|
})
|
||||||
|
if err := dao.Mdb.Create(&mdb.Setting{
|
||||||
|
Group: "rate",
|
||||||
|
Key: "rate.forced_rate_list",
|
||||||
|
Value: `{"cny":{"usdt":1}}`,
|
||||||
|
Type: "json",
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed rate.forced_rate_list: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
return func() {
|
return func() {
|
||||||
closeDB(t, runtimeDB)
|
closeDB(t, runtimeDB)
|
||||||
closeDB(t, mainDB)
|
closeDB(t, mainDB)
|
||||||
dao.Mdb = nil
|
dao.Mdb = nil
|
||||||
dao.RuntimeDB = nil
|
dao.RuntimeDB = nil
|
||||||
|
config.SettingsGetString = nil
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ import (
|
|||||||
"github.com/gookit/color"
|
"github.com/gookit/color"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:static
|
|
||||||
var staticDir embed.FS
|
|
||||||
|
|
||||||
//go:embed all:www
|
//go:embed all:www
|
||||||
var wwwDir embed.FS
|
var wwwDir embed.FS
|
||||||
|
|
||||||
@@ -92,12 +89,6 @@ func releaseStatic(fs embed.FS, target string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
staticPath, err := releaseStatic(staticDir, "static")
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
fmt.Println("static released to:", staticPath)
|
|
||||||
|
|
||||||
wwwwPath, err := releaseStatic(wwwDir, "www")
|
wwwwPath, err := releaseStatic(wwwDir, "www")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|||||||
+23
-10
@@ -27,16 +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 {
|
||||||
raw := strings.TrimSpace(ctx.Request().Header.Get("Authorization"))
|
token, err := adminTokenFromAuthorization(ctx.Request().Header.Get("Authorization"))
|
||||||
if raw == "" {
|
if err != nil {
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
|
return err
|
||||||
}
|
|
||||||
if !strings.HasPrefix(raw, "Bearer ") {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "authorization header must use Bearer scheme")
|
|
||||||
}
|
|
||||||
token := strings.TrimSpace(raw[len("Bearer "):])
|
|
||||||
if token == "" {
|
|
||||||
return echo.NewHTTPError(http.StatusUnauthorized, "empty token")
|
|
||||||
}
|
}
|
||||||
claims, err := appjwt.Parse(token)
|
claims, err := appjwt.Parse(token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -48,3 +41,23 @@ func CheckAdminJWT() echo.MiddlewareFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func adminTokenFromAuthorization(header string) (string, error) {
|
||||||
|
raw := strings.TrimSpace(header)
|
||||||
|
if raw == "" {
|
||||||
|
return "", echo.NewHTTPError(http.StatusUnauthorized, "missing authorization header")
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearerPrefix = "Bearer "
|
||||||
|
token := raw
|
||||||
|
if strings.HasPrefix(raw, bearerPrefix) {
|
||||||
|
token = strings.TrimSpace(raw[len(bearerPrefix):])
|
||||||
|
} else if raw == strings.TrimSpace(bearerPrefix) {
|
||||||
|
token = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
return "", echo.NewHTTPError(http.StatusUnauthorized, "empty token")
|
||||||
|
}
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
appjwt "github.com/GMWalletApp/epusdt/util/jwt"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdminTokenFromAuthorization(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
want string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bearer token",
|
||||||
|
header: "Bearer jwt-token",
|
||||||
|
want: "jwt-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare token",
|
||||||
|
header: "jwt-token",
|
||||||
|
want: "jwt-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trimmed bare token",
|
||||||
|
header: " jwt-token ",
|
||||||
|
want: "jwt-token",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing header",
|
||||||
|
header: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty bearer token",
|
||||||
|
header: "Bearer ",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := adminTokenFromAuthorization(tt.header)
|
||||||
|
if tt.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("token = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAdminJWTAcceptsBearerAndBareTokens(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyJwtSecret, "test-jwt-secret", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed jwt secret: %v", err)
|
||||||
|
}
|
||||||
|
token, err := appjwt.Sign(42, "admin")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign jwt: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
header string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bearer token",
|
||||||
|
header: "Bearer " + token,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bare token",
|
||||||
|
header: token,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
e := echo.New()
|
||||||
|
e.Use(CheckAdminJWT())
|
||||||
|
e.GET("/", func(ctx echo.Context) error {
|
||||||
|
if got := ctx.Get(AdminUserIDKey); got != uint64(42) {
|
||||||
|
t.Fatalf("%s = %v, want 42", AdminUserIDKey, got)
|
||||||
|
}
|
||||||
|
if got := ctx.Get(AdminUsernameKey); got != "admin" {
|
||||||
|
t.Fatalf("%s = %v, want admin", AdminUsernameKey, got)
|
||||||
|
}
|
||||||
|
return ctx.NoContent(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.Header.Set(echo.HeaderAuthorization, tt.header)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusNoContent, rec.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/gookit/color"
|
"github.com/gookit/color"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -35,6 +36,7 @@ func MdbTableInit() {
|
|||||||
{"Chain", &mdb.Chain{}},
|
{"Chain", &mdb.Chain{}},
|
||||||
{"ChainToken", &mdb.ChainToken{}},
|
{"ChainToken", &mdb.ChainToken{}},
|
||||||
{"RpcNode", &mdb.RpcNode{}},
|
{"RpcNode", &mdb.RpcNode{}},
|
||||||
|
{"ProviderOrder", &mdb.ProviderOrder{}},
|
||||||
}
|
}
|
||||||
for _, m := range migrations {
|
for _, m := range migrations {
|
||||||
if err := Mdb.AutoMigrate(m.model); err != nil {
|
if err := Mdb.AutoMigrate(m.model); err != nil {
|
||||||
@@ -44,6 +46,7 @@ func MdbTableInit() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
seedChains()
|
seedChains()
|
||||||
|
backfillRpcNodePurpose()
|
||||||
seedRpcNodes()
|
seedRpcNodes()
|
||||||
seedChainTokens()
|
seedChainTokens()
|
||||||
seedDefaultSettings()
|
seedDefaultSettings()
|
||||||
@@ -61,6 +64,8 @@ func seedChains() {
|
|||||||
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||||
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||||
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||||
|
{Network: mdb.NetworkTon, DisplayName: "TON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||||
|
{Network: mdb.NetworkAptos, DisplayName: "Aptos", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||||
}
|
}
|
||||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||||
color.Red.Printf("[store_db] seed chains err=%s\n", err)
|
color.Red.Printf("[store_db] seed chains err=%s\n", err)
|
||||||
@@ -95,6 +100,11 @@ func seedChainTokens() {
|
|||||||
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
|
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
|
||||||
// Plasma
|
// Plasma
|
||||||
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
|
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
|
||||||
|
// TON
|
||||||
|
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||||
|
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true},
|
||||||
|
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b", Decimals: 6, Enabled: true},
|
||||||
|
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b", Decimals: 6, Enabled: true},
|
||||||
}
|
}
|
||||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||||
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
|
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
|
||||||
@@ -104,14 +114,7 @@ func seedChainTokens() {
|
|||||||
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
|
// seedRpcNodes inserts default RPC endpoints for each chain. Checks per
|
||||||
// (network, url) so re-runs are idempotent and admin edits persist.
|
// (network, url) so re-runs are idempotent and admin edits persist.
|
||||||
func seedRpcNodes() {
|
func seedRpcNodes() {
|
||||||
defaults := []mdb.RpcNode{
|
defaults := defaultRpcNodes()
|
||||||
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
|
||||||
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
|
||||||
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
|
||||||
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
|
||||||
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
|
||||||
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Status: mdb.RpcNodeStatusUnknown},
|
|
||||||
}
|
|
||||||
for _, d := range defaults {
|
for _, d := range defaults {
|
||||||
var count int64
|
var count int64
|
||||||
if err := Mdb.Model(&mdb.RpcNode{}).
|
if err := Mdb.Model(&mdb.RpcNode{}).
|
||||||
@@ -129,13 +132,49 @@ func seedRpcNodes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultRpcNodes() []mdb.RpcNode {
|
||||||
|
defaults := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://api.trongrid.io", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkEthereum, Url: "wss://ethereum.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkSolana, Url: "https://api.mainnet-beta.solana.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkTon, Url: "https://ton-blockchain.github.io/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkAptos, Url: "https://aptos-rest.publicnode.com/", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkBsc, Url: "https://rpc.epusdt.com/binance", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
}
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
func backfillRpcNodePurpose() {
|
||||||
|
if err := Mdb.Model(&mdb.RpcNode{}).
|
||||||
|
Where("purpose = '' OR purpose IS NULL").
|
||||||
|
Update("purpose", mdb.RpcNodePurposeGeneral).Error; err != nil {
|
||||||
|
color.Red.Printf("[store_db] backfill rpc_nodes purpose err=%s\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// seedDefaultSettings inserts built-in default settings.
|
// seedDefaultSettings inserts built-in default settings.
|
||||||
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
|
// Uses ON CONFLICT DO NOTHING so admin edits persist across restarts.
|
||||||
func seedDefaultSettings() {
|
func seedDefaultSettings() {
|
||||||
|
okPayCallbackURL := strings.TrimRight(strings.TrimSpace(config.GetAppUri()), "/")
|
||||||
|
if okPayCallbackURL != "" {
|
||||||
|
okPayCallbackURL += "/payments/okpay/v1/notify"
|
||||||
|
}
|
||||||
defaults := []mdb.Setting{
|
defaults := []mdb.Setting{
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
|
||||||
|
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeySystemLogLevel, Value: mdb.SettingDefaultSystemLogLevel, Type: mdb.SettingTypeString},
|
||||||
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "", Type: mdb.SettingTypeString},
|
||||||
|
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayEnabled, Value: "false", Type: mdb.SettingTypeBool},
|
||||||
|
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAPIURL, Value: "https://api.okaypay.me/shop/", Type: mdb.SettingTypeString},
|
||||||
|
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayCallbackURL, Value: okPayCallbackURL, Type: mdb.SettingTypeString},
|
||||||
|
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayTimeoutSeconds, Value: "10", Type: mdb.SettingTypeInt},
|
||||||
|
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAllowTokens, Value: "USDT,TRX", Type: mdb.SettingTypeString},
|
||||||
}
|
}
|
||||||
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 default settings err=%s\n", err)
|
color.Red.Printf("[store_db] seed default settings err=%s\n", err)
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
package dao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/libtnb/sqlite"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultRpcNodesIncludesManualVerifyEpusdtEvmNodes(t *testing.T) {
|
||||||
|
want := map[string]string{
|
||||||
|
mdb.NetworkEthereum: "https://rpc.epusdt.com/ethereum",
|
||||||
|
mdb.NetworkBsc: "https://rpc.epusdt.com/binance",
|
||||||
|
mdb.NetworkPolygon: "https://rpc.epusdt.com/polygon",
|
||||||
|
}
|
||||||
|
got := make(map[string]mdb.RpcNode)
|
||||||
|
for _, node := range defaultRpcNodes() {
|
||||||
|
if node.Purpose != mdb.RpcNodePurposeManualVerify {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := want[node.Network]; ok {
|
||||||
|
got[node.Network] = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for network, url := range want {
|
||||||
|
node, ok := got[network]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing manual_verify seed rpc node for %s", network)
|
||||||
|
}
|
||||||
|
if node.Url != url {
|
||||||
|
t.Fatalf("%s manual_verify seed url = %q, want %q", network, node.Url, url)
|
||||||
|
}
|
||||||
|
if node.Type != mdb.RpcNodeTypeHttp {
|
||||||
|
t.Fatalf("%s manual_verify seed type = %q, want %q", network, node.Type, mdb.RpcNodeTypeHttp)
|
||||||
|
}
|
||||||
|
if !node.Enabled {
|
||||||
|
t.Fatalf("%s manual_verify seed enabled = false, want true", network)
|
||||||
|
}
|
||||||
|
if node.Status != mdb.RpcNodeStatusUnknown {
|
||||||
|
t.Fatalf("%s manual_verify seed status = %q, want %q", network, node.Status, mdb.RpcNodeStatusUnknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRpcNodesIncludesTonLiteGeneralNode(t *testing.T) {
|
||||||
|
var got *mdb.RpcNode
|
||||||
|
nodes := defaultRpcNodes()
|
||||||
|
for i := range nodes {
|
||||||
|
node := nodes[i]
|
||||||
|
if node.Network == mdb.NetworkTon && node.Type == mdb.RpcNodeTypeLite {
|
||||||
|
got = &node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("missing TON lite seed rpc node")
|
||||||
|
}
|
||||||
|
if got.Url != "https://ton-blockchain.github.io/global.config.json" {
|
||||||
|
t.Fatalf("TON lite seed url = %q", got.Url)
|
||||||
|
}
|
||||||
|
if got.Purpose != mdb.RpcNodePurposeGeneral {
|
||||||
|
t.Fatalf("TON lite seed purpose = %q, want %q", got.Purpose, mdb.RpcNodePurposeGeneral)
|
||||||
|
}
|
||||||
|
if !got.Enabled {
|
||||||
|
t.Fatal("TON lite seed enabled = false, want true")
|
||||||
|
}
|
||||||
|
if got.Status != mdb.RpcNodeStatusUnknown {
|
||||||
|
t.Fatalf("TON lite seed status = %q, want %q", got.Status, mdb.RpcNodeStatusUnknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultRpcNodesIncludesAptosPublicNode(t *testing.T) {
|
||||||
|
var got *mdb.RpcNode
|
||||||
|
nodes := defaultRpcNodes()
|
||||||
|
for i := range nodes {
|
||||||
|
node := nodes[i]
|
||||||
|
if node.Network == mdb.NetworkAptos {
|
||||||
|
got = &node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("missing Aptos seed rpc node")
|
||||||
|
}
|
||||||
|
if got.Url != "https://aptos-rest.publicnode.com/" {
|
||||||
|
t.Fatalf("Aptos seed url = %q", got.Url)
|
||||||
|
}
|
||||||
|
if got.Type != mdb.RpcNodeTypeHttp {
|
||||||
|
t.Fatalf("Aptos seed type = %q, want %q", got.Type, mdb.RpcNodeTypeHttp)
|
||||||
|
}
|
||||||
|
if got.Purpose != mdb.RpcNodePurposeGeneral {
|
||||||
|
t.Fatalf("Aptos seed purpose = %q, want %q", got.Purpose, mdb.RpcNodePurposeGeneral)
|
||||||
|
}
|
||||||
|
if !got.Enabled {
|
||||||
|
t.Fatal("Aptos seed enabled = false, want true")
|
||||||
|
}
|
||||||
|
if got.Status != mdb.RpcNodeStatusUnknown {
|
||||||
|
t.Fatalf("Aptos seed status = %q, want %q", got.Status, mdb.RpcNodeStatusUnknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedChainsIncludesAptos(t *testing.T) {
|
||||||
|
db := setupSeedTableTestDB(t, &mdb.Chain{})
|
||||||
|
Mdb = db
|
||||||
|
|
||||||
|
seedChains()
|
||||||
|
|
||||||
|
var row mdb.Chain
|
||||||
|
if err := Mdb.Where("network = ?", mdb.NetworkAptos).Take(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load Aptos chain seed: %v", err)
|
||||||
|
}
|
||||||
|
if !row.Enabled {
|
||||||
|
t.Fatal("Aptos chain enabled = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedChainTokensIncludesAptosAssets(t *testing.T) {
|
||||||
|
db := setupSeedTableTestDB(t, &mdb.ChainToken{})
|
||||||
|
Mdb = db
|
||||||
|
|
||||||
|
seedChainTokens()
|
||||||
|
|
||||||
|
wantEnabled := map[string]bool{
|
||||||
|
mdb.NetworkAptos + "/USDC": true,
|
||||||
|
mdb.NetworkAptos + "/USDT": true,
|
||||||
|
}
|
||||||
|
wantContract := map[string]string{
|
||||||
|
mdb.NetworkAptos + "/USDT": "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b",
|
||||||
|
}
|
||||||
|
for key, enabled := range wantEnabled {
|
||||||
|
parts := strings.Split(key, "/")
|
||||||
|
var row mdb.ChainToken
|
||||||
|
if err := Mdb.Where("network = ? AND symbol = ?", parts[0], parts[1]).Take(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load token seed %s: %v", key, err)
|
||||||
|
}
|
||||||
|
if row.Enabled != enabled {
|
||||||
|
t.Fatalf("%s enabled = %v, want %v", key, row.Enabled, enabled)
|
||||||
|
}
|
||||||
|
if want, ok := wantContract[key]; ok && row.ContractAddress != want {
|
||||||
|
t.Fatalf("%s contract_address = %q, want %q", key, row.ContractAddress, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedDefaultSettingsIncludesSystemLogLevel(t *testing.T) {
|
||||||
|
db := setupSeedSettingsTestDB(t)
|
||||||
|
Mdb = db
|
||||||
|
|
||||||
|
seedDefaultSettings()
|
||||||
|
|
||||||
|
var row mdb.Setting
|
||||||
|
if err := Mdb.Where("`key` = ?", mdb.SettingKeySystemLogLevel).Take(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load system.log_level seed: %v", err)
|
||||||
|
}
|
||||||
|
if row.Group != mdb.SettingGroupSystem {
|
||||||
|
t.Fatalf("system.log_level group = %q, want %q", row.Group, mdb.SettingGroupSystem)
|
||||||
|
}
|
||||||
|
if row.Value != mdb.SettingDefaultSystemLogLevel {
|
||||||
|
t.Fatalf("system.log_level value = %q, want %q", row.Value, mdb.SettingDefaultSystemLogLevel)
|
||||||
|
}
|
||||||
|
if row.Type != mdb.SettingTypeString {
|
||||||
|
t.Fatalf("system.log_level type = %q, want %q", row.Type, mdb.SettingTypeString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedDefaultSettingsUsesEmptyEpayTokenAndNetwork(t *testing.T) {
|
||||||
|
db := setupSeedSettingsTestDB(t)
|
||||||
|
Mdb = db
|
||||||
|
|
||||||
|
seedDefaultSettings()
|
||||||
|
|
||||||
|
rows := make(map[string]mdb.Setting)
|
||||||
|
for _, key := range []string{
|
||||||
|
mdb.SettingKeyEpayDefaultToken,
|
||||||
|
mdb.SettingKeyEpayDefaultCurrency,
|
||||||
|
mdb.SettingKeyEpayDefaultNetwork,
|
||||||
|
} {
|
||||||
|
var row mdb.Setting
|
||||||
|
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load %s seed: %v", key, err)
|
||||||
|
}
|
||||||
|
rows[key] = row
|
||||||
|
}
|
||||||
|
if rows[mdb.SettingKeyEpayDefaultToken].Value != "" {
|
||||||
|
t.Fatalf("epay.default_token seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultToken].Value)
|
||||||
|
}
|
||||||
|
if rows[mdb.SettingKeyEpayDefaultNetwork].Value != "" {
|
||||||
|
t.Fatalf("epay.default_network seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultNetwork].Value)
|
||||||
|
}
|
||||||
|
if rows[mdb.SettingKeyEpayDefaultCurrency].Value != "cny" {
|
||||||
|
t.Fatalf("epay.default_currency seed = %q, want cny", rows[mdb.SettingKeyEpayDefaultCurrency].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedDefaultSettingsDoesNotOverwriteExistingEpayDefaults(t *testing.T) {
|
||||||
|
db := setupSeedSettingsTestDB(t)
|
||||||
|
Mdb = db
|
||||||
|
for _, row := range []mdb.Setting{
|
||||||
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
|
||||||
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
|
||||||
|
} {
|
||||||
|
if err := Mdb.Create(&row).Error; err != nil {
|
||||||
|
t.Fatalf("precreate %s: %v", row.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedDefaultSettings()
|
||||||
|
|
||||||
|
for key, want := range map[string]string{
|
||||||
|
mdb.SettingKeyEpayDefaultToken: "usdt",
|
||||||
|
mdb.SettingKeyEpayDefaultNetwork: "tron",
|
||||||
|
} {
|
||||||
|
var row mdb.Setting
|
||||||
|
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load %s seed: %v", key, err)
|
||||||
|
}
|
||||||
|
if row.Value != want {
|
||||||
|
t.Fatalf("%s value = %q, want existing %q", key, row.Value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedDefaultSettingsDoesNotOverwriteSystemLogLevel(t *testing.T) {
|
||||||
|
db := setupSeedSettingsTestDB(t)
|
||||||
|
Mdb = db
|
||||||
|
if err := Mdb.Create(&mdb.Setting{
|
||||||
|
Group: mdb.SettingGroupSystem,
|
||||||
|
Key: mdb.SettingKeySystemLogLevel,
|
||||||
|
Value: "debug",
|
||||||
|
Type: mdb.SettingTypeString,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("precreate system.log_level: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seedDefaultSettings()
|
||||||
|
|
||||||
|
var row mdb.Setting
|
||||||
|
if err := Mdb.Where("`key` = ?", mdb.SettingKeySystemLogLevel).Take(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load system.log_level seed: %v", err)
|
||||||
|
}
|
||||||
|
if row.Value != "debug" {
|
||||||
|
t.Fatalf("system.log_level value = %q, want existing debug", row.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupSeedSettingsTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
oldDB := Mdb
|
||||||
|
viper.Reset()
|
||||||
|
viper.Set("app_uri", "https://example.com")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
Mdb = oldDB
|
||||||
|
viper.Reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "seed-settings.db")), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&mdb.Setting{}); err != nil {
|
||||||
|
t.Fatalf("migrate settings: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupSeedTableTestDB(t *testing.T, models ...interface{}) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
oldDB := Mdb
|
||||||
|
t.Cleanup(func() {
|
||||||
|
Mdb = oldDB
|
||||||
|
})
|
||||||
|
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "seed-table.db")), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(models...); err != nil {
|
||||||
|
t.Fatalf("migrate seed table: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
@@ -44,6 +44,9 @@ func RuntimeInit() error {
|
|||||||
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_address_token_amount_uindex").Error; err != nil {
|
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_address_token_amount_uindex").Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_network_address_token_amount_uindex").Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
|
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
|
||||||
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
|
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ type InitialAdminPasswordHashInfo struct {
|
|||||||
// can print it to the console. Idempotent — subsequent calls return
|
// can print it to the console. Idempotent — subsequent calls return
|
||||||
// ("", false, nil).
|
// ("", false, nil).
|
||||||
func EnsureDefaultAdmin() (password string, created bool, err error) {
|
func EnsureDefaultAdmin() (password string, created bool, err error) {
|
||||||
|
if err := purgeDeletedInitialAdminPasswordPlain(); err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
var count int64
|
var count int64
|
||||||
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
|
if err := dao.Mdb.Model(&mdb.AdminUser{}).Count(&count).Error; err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
@@ -68,6 +71,12 @@ func EnsureDefaultAdmin() (password string, created bool, err error) {
|
|||||||
return password, true, nil
|
return password, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func purgeDeletedInitialAdminPasswordPlain() error {
|
||||||
|
return dao.Mdb.Unscoped().
|
||||||
|
Where("`key` = ? AND deleted_at IS NOT NULL", mdb.SettingKeyInitAdminPasswordPlain).
|
||||||
|
Delete(&mdb.Setting{}).Error
|
||||||
|
}
|
||||||
|
|
||||||
func randomAdminPassword() string {
|
func randomAdminPassword() string {
|
||||||
b := make([]byte, 16)
|
b := make([]byte, 16)
|
||||||
_, _ = rand.Read(b)
|
_, _ = rand.Read(b)
|
||||||
@@ -121,9 +130,11 @@ func initAdminPasswordState(plain string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
|
func upsertSettingRow(tx *gorm.DB, row mdb.Setting) error {
|
||||||
|
updates := clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"})
|
||||||
|
updates = append(updates, clause.Assignment{Column: clause.Column{Name: "deleted_at"}, Value: nil})
|
||||||
return tx.Clauses(clause.OnConflict{
|
return tx.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "key"}},
|
Columns: []clause.Column{{Name: "key"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "description", "updated_at"}),
|
DoUpdates: updates,
|
||||||
}).Create(&row).Error
|
}).Create(&row).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,7 +165,7 @@ func ConsumeInitialAdminPassword() (string, error) {
|
|||||||
return ErrInitAdminPasswordUnavailable
|
return ErrInitAdminPasswordUnavailable
|
||||||
}
|
}
|
||||||
password = row.Value
|
password = row.Value
|
||||||
res := tx.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
|
res := tx.Unscoped().Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{})
|
||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
return res.Error
|
return res.Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUpsertSettingRowRestoresSoftDeletedSetting(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
row := mdb.Setting{
|
||||||
|
Group: mdb.SettingGroupSystem,
|
||||||
|
Key: mdb.SettingKeyInitAdminPasswordChanged,
|
||||||
|
Value: "false",
|
||||||
|
Type: mdb.SettingTypeBool,
|
||||||
|
}
|
||||||
|
if err := upsertSettingRow(dao.Mdb, row); err != nil {
|
||||||
|
t.Fatalf("seed setting: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Where("`key` = ?", row.Key).Delete(&mdb.Setting{}).Error; err != nil {
|
||||||
|
t.Fatalf("delete setting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
row.Value = "true"
|
||||||
|
if err := upsertSettingRow(dao.Mdb, row); err != nil {
|
||||||
|
t.Fatalf("restore setting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var restored mdb.Setting
|
||||||
|
if err := dao.Mdb.Where("`key` = ?", row.Key).Take(&restored).Error; err != nil {
|
||||||
|
t.Fatalf("load restored setting: %v", err)
|
||||||
|
}
|
||||||
|
if restored.Value != "true" {
|
||||||
|
t.Fatalf("restored value = %q, want true", restored.Value)
|
||||||
|
}
|
||||||
|
if restored.DeletedAt.Valid {
|
||||||
|
t.Fatalf("restored setting still has deleted_at=%v", restored.DeletedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConsumeInitialAdminPasswordHardDeletesPlaintext(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
const password = "init-pass-plain"
|
||||||
|
if err := initAdminPasswordState(password); err != nil {
|
||||||
|
t.Fatalf("seed initial password state: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ConsumeInitialAdminPassword()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("consume initial password: %v", err)
|
||||||
|
}
|
||||||
|
if got != password {
|
||||||
|
t.Fatalf("password = %q, want %q", got, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := dao.Mdb.Unscoped().
|
||||||
|
Model(&mdb.Setting{}).
|
||||||
|
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count plaintext setting: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("plaintext setting rows after consume = %d, want 0", count)
|
||||||
|
}
|
||||||
|
if !GetSettingBool(mdb.SettingKeyInitAdminPasswordFetched, false) {
|
||||||
|
t.Fatal("expected fetched flag to be true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureDefaultAdminPurgesLegacySoftDeletedPlaintext(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
hash, err := HashPassword("existing-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("hash password: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&mdb.AdminUser{
|
||||||
|
Username: defaultAdminUsername,
|
||||||
|
PasswordHash: hash,
|
||||||
|
Status: mdb.AdminUserStatusEnable,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed admin: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&mdb.Setting{
|
||||||
|
Group: mdb.SettingGroupSystem,
|
||||||
|
Key: mdb.SettingKeyInitAdminPasswordPlain,
|
||||||
|
Value: "legacy-soft-deleted-plain",
|
||||||
|
Type: mdb.SettingTypeString,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed plaintext setting: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).Delete(&mdb.Setting{}).Error; err != nil {
|
||||||
|
t.Fatalf("soft delete plaintext setting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
password, created, err := EnsureDefaultAdmin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ensure default admin: %v", err)
|
||||||
|
}
|
||||||
|
if created || password != "" {
|
||||||
|
t.Fatalf("created=%v password=%q, want existing admin unchanged", created, password)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := dao.Mdb.Unscoped().
|
||||||
|
Model(&mdb.Setting{}).
|
||||||
|
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count plaintext setting: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("legacy plaintext rows after ensure = %d, want 0", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,12 +105,12 @@ func CountOrdersByStatus() (map[int]int64, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseOrderManually transitions a pending order to expired. Only
|
// CloseOrderManually transitions a pending order to expired. It covers both
|
||||||
// touches rows currently in StatusWaitPay so idempotent / safe.
|
// payable orders and placeholder orders waiting for token/network selection.
|
||||||
func CloseOrderManually(tradeID string) (bool, error) {
|
func CloseOrderManually(tradeID string) (bool, error) {
|
||||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
Where("trade_id = ?", tradeID).
|
Where("trade_id = ?", tradeID).
|
||||||
Where("status = ?", mdb.StatusWaitPay).
|
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
||||||
Update("status", mdb.StatusExpired)
|
Update("status", mdb.StatusExpired)
|
||||||
return result.RowsAffected > 0, result.Error
|
return result.RowsAffected > 0, result.Error
|
||||||
}
|
}
|
||||||
|
|||||||
+217
-32
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -23,9 +24,14 @@ type PendingCallbackOrder struct {
|
|||||||
UpdatedAt carbon.Time `gorm:"column:updated_at"`
|
UpdatedAt carbon.Time `gorm:"column:updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeAmount(amount float64, precision int) (int64, string) {
|
||||||
|
precision = NormalizeAmountPrecision(precision)
|
||||||
|
value := decimal.NewFromFloat(amount).Round(int32(precision))
|
||||||
|
return value.Shift(int32(precision)).IntPart(), value.StringFixed(int32(precision))
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeLockAmount(amount float64) (int64, string) {
|
func normalizeLockAmount(amount float64) (int64, string) {
|
||||||
value := decimal.NewFromFloat(amount).Round(2)
|
return normalizeAmount(amount, GetAmountPrecision())
|
||||||
return value.Shift(2).IntPart(), value.StringFixed(2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeLockNetwork(network string) string {
|
func normalizeLockNetwork(network string) string {
|
||||||
@@ -34,9 +40,20 @@ func normalizeLockNetwork(network string) string {
|
|||||||
|
|
||||||
func normalizeLockAddress(network, address string) string {
|
func normalizeLockAddress(network, address string) string {
|
||||||
address = strings.TrimSpace(address)
|
address = strings.TrimSpace(address)
|
||||||
if isEVMNetwork(normalizeLockNetwork(network)) {
|
network = normalizeLockNetwork(network)
|
||||||
|
if isEVMNetwork(network) {
|
||||||
return strings.ToLower(address)
|
return strings.ToLower(address)
|
||||||
}
|
}
|
||||||
|
if network == mdb.NetworkTon {
|
||||||
|
if raw, err := addressutil.TonRawAddressKey(address); err == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if network == mdb.NetworkAptos {
|
||||||
|
if normalized, err := addressutil.NormalizeMoveAddress(address); err == nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +70,30 @@ func applyLockAddressFilter(tx *gorm.DB, network, address string) *gorm.DB {
|
|||||||
return tx.Where("address = ?", address)
|
return tx.Where("address = ?", address)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func activeLocksForAddress(tx *gorm.DB, network, address, token string, now time.Time) *gorm.DB {
|
||||||
|
query := tx.Model(&mdb.TransactionLock{}).
|
||||||
|
Where("network = ?", normalizeLockNetwork(network)).
|
||||||
|
Where("token = ?", normalizeLockToken(token)).
|
||||||
|
Where("expires_at > ?", now)
|
||||||
|
return applyLockAddressFilter(query, network, address)
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockMatchesAmount(lock mdb.TransactionLock, amount float64) bool {
|
||||||
|
precision := NormalizeAmountPrecision(lock.AmountPrecision)
|
||||||
|
scaledAmount, _ := normalizeAmount(amount, precision)
|
||||||
|
return lock.AmountScaled == scaledAmount
|
||||||
|
}
|
||||||
|
|
||||||
|
func lockAmountDecimal(lock mdb.TransactionLock) decimal.Decimal {
|
||||||
|
if strings.TrimSpace(lock.AmountText) != "" {
|
||||||
|
if value, err := decimal.NewFromString(lock.AmountText); err == nil {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
precision := NormalizeAmountPrecision(lock.AmountPrecision)
|
||||||
|
return decimal.NewFromInt(lock.AmountScaled).Shift(int32(-precision))
|
||||||
|
}
|
||||||
|
|
||||||
// GetOrderInfoByOrderId fetches an order by merchant order id.
|
// GetOrderInfoByOrderId fetches an order by merchant order id.
|
||||||
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
|
func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) {
|
||||||
order := new(mdb.Orders)
|
order := new(mdb.Orders)
|
||||||
@@ -79,6 +120,56 @@ func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders,
|
|||||||
return order, err
|
return order, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOrderByBlockTransactionIDs fetches the first order whose stored tx id
|
||||||
|
// matches any equivalent spelling supplied by the caller.
|
||||||
|
func GetOrderByBlockTransactionIDs(blockIDs []string) (*mdb.Orders, error) {
|
||||||
|
order := new(mdb.Orders)
|
||||||
|
seen := make(map[string]struct{}, len(blockIDs))
|
||||||
|
candidates := make([]string, 0, len(blockIDs))
|
||||||
|
for _, blockID := range blockIDs {
|
||||||
|
blockID = strings.TrimSpace(blockID)
|
||||||
|
if blockID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[blockID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[blockID] = struct{}{}
|
||||||
|
candidates = append(candidates, blockID)
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
err := dao.Mdb.Model(order).Where("block_transaction_id IN ?", candidates).Limit(1).Find(order).Error
|
||||||
|
return order, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrderByBlockTransactionIDsCaseInsensitive fetches the first order whose
|
||||||
|
// stored tx id matches any candidate after ASCII case folding. This is used
|
||||||
|
// only for hex-based chain tx ids; case-sensitive signatures must keep using
|
||||||
|
// GetOrderByBlockTransactionIDs.
|
||||||
|
func GetOrderByBlockTransactionIDsCaseInsensitive(blockIDs []string) (*mdb.Orders, error) {
|
||||||
|
order := new(mdb.Orders)
|
||||||
|
seen := make(map[string]struct{}, len(blockIDs))
|
||||||
|
candidates := make([]string, 0, len(blockIDs))
|
||||||
|
for _, blockID := range blockIDs {
|
||||||
|
blockID = strings.ToLower(strings.TrimSpace(blockID))
|
||||||
|
if blockID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[blockID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[blockID] = struct{}{}
|
||||||
|
candidates = append(candidates, blockID)
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
err := dao.Mdb.Model(order).Where("LOWER(block_transaction_id) IN ?", candidates).Limit(1).Find(order).Error
|
||||||
|
return order, err
|
||||||
|
}
|
||||||
|
|
||||||
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
|
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
|
||||||
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
|
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
|
||||||
result := tx.Model(&mdb.Orders{}).
|
result := tx.Model(&mdb.Orders{}).
|
||||||
@@ -123,7 +214,7 @@ func SaveCallBackOrdersResp(order *mdb.Orders) error {
|
|||||||
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
|
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
|
||||||
result := dao.Mdb.Model(mdb.Orders{}).
|
result := dao.Mdb.Model(mdb.Orders{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Where("status = ?", mdb.StatusWaitPay).
|
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
||||||
Where("created_at <= ?", expirationCutoff).
|
Where("created_at <= ?", expirationCutoff).
|
||||||
Update("status", mdb.StatusExpired)
|
Update("status", mdb.StatusExpired)
|
||||||
return result.RowsAffected > 0, result.Error
|
return result.RowsAffected > 0, result.Error
|
||||||
@@ -139,6 +230,15 @@ func CountActiveSubOrders(parentTradeId string) (int64, error) {
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountSubOrders counts all sub-orders ever created under a parent.
|
||||||
|
func CountSubOrders(parentTradeId string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Where("parent_trade_id = ?", parentTradeId).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent.
|
// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent.
|
||||||
func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) {
|
func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) {
|
||||||
order := new(mdb.Orders)
|
order := new(mdb.Orders)
|
||||||
@@ -202,6 +302,73 @@ func MarkOrderSelected(tradeId string) error {
|
|||||||
Update("is_selected", true).Error
|
Update("is_selected", true).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CompleteWaitSelectOrder fills a placeholder order with concrete chain fields
|
||||||
|
// and makes it payable. It only updates status=WaitSelect rows so concurrent
|
||||||
|
// switch-network attempts cannot overwrite a payable order.
|
||||||
|
func CompleteWaitSelectOrder(tradeID string, network string, token string, receiveAddress string, actualAmount float64) (bool, error) {
|
||||||
|
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("status = ?", mdb.StatusWaitSelect).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": mdb.StatusWaitPay,
|
||||||
|
"network": strings.ToLower(strings.TrimSpace(network)),
|
||||||
|
"token": strings.ToUpper(strings.TrimSpace(token)),
|
||||||
|
"receive_address": receiveAddress,
|
||||||
|
"actual_amount": actualAmount,
|
||||||
|
"is_selected": false,
|
||||||
|
"created_at": time.Now(),
|
||||||
|
})
|
||||||
|
return result.RowsAffected > 0, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteWaitSelectOkPayOrderWithTransaction converts a placeholder parent
|
||||||
|
// directly into an OkPay order. It intentionally does not create a local chain
|
||||||
|
// lock because OkPay owns the hosted payment target.
|
||||||
|
func CompleteWaitSelectOkPayOrderWithTransaction(tx *gorm.DB, tradeID string, token string, actualAmount float64) (bool, error) {
|
||||||
|
result := tx.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("status = ?", mdb.StatusWaitSelect).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": mdb.StatusWaitPay,
|
||||||
|
"network": mdb.PaymentProviderOkPay,
|
||||||
|
"token": strings.ToUpper(strings.TrimSpace(token)),
|
||||||
|
"receive_address": "OKPAY",
|
||||||
|
"actual_amount": actualAmount,
|
||||||
|
"is_selected": false,
|
||||||
|
"pay_provider": mdb.PaymentProviderOkPay,
|
||||||
|
"created_at": time.Now(),
|
||||||
|
})
|
||||||
|
return result.RowsAffected > 0, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkProviderSwitchParentSelectedWithTransaction marks a switch-network parent
|
||||||
|
// as selected for a hosted payment provider. It intentionally does not fill
|
||||||
|
// chain fields or create a transaction lock; the provider child order carries
|
||||||
|
// the concrete payment target.
|
||||||
|
func MarkProviderSwitchParentSelectedWithTransaction(tx *gorm.DB, tradeID string) (bool, error) {
|
||||||
|
result := tx.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": mdb.StatusWaitPay,
|
||||||
|
"is_selected": true,
|
||||||
|
"created_at": time.Now(),
|
||||||
|
})
|
||||||
|
return result.RowsAffected > 0, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExpireOrderByTradeID marks a waiting order as expired. Used to retire failed
|
||||||
|
// child-order attempts that should not remain selectable/reusable.
|
||||||
|
func ExpireOrderByTradeID(tradeId string) error {
|
||||||
|
return dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", tradeId).
|
||||||
|
Where("status = ?", mdb.StatusWaitPay).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": mdb.StatusExpired,
|
||||||
|
"is_selected": false,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
// RefreshOrderExpiration resets created_at to now so the expiration timer restarts.
|
// RefreshOrderExpiration resets created_at to now so the expiration timer restarts.
|
||||||
// Called on the parent order when a sub-order is created or returned.
|
// Called on the parent order when a sub-order is created or returned.
|
||||||
func RefreshOrderExpiration(tradeId string) error {
|
func RefreshOrderExpiration(tradeId string) error {
|
||||||
@@ -266,45 +433,43 @@ func ExpireOrderByTradeId(tradeId string) error {
|
|||||||
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
|
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
|
||||||
network = normalizeLockNetwork(network)
|
network = normalizeLockNetwork(network)
|
||||||
address = normalizeLockAddress(network, address)
|
address = normalizeLockAddress(network, address)
|
||||||
scaledAmount, _ := normalizeLockAmount(amount)
|
var locks []mdb.TransactionLock
|
||||||
var lock mdb.TransactionLock
|
err := activeLocksForAddress(dao.RuntimeDB, network, address, token, time.Now()).
|
||||||
query := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
Order("created_at ASC").
|
||||||
Where("network = ?", network).
|
Find(&locks).Error
|
||||||
Where("token = ?", normalizeLockToken(token)).
|
|
||||||
Where("amount_scaled = ?", scaledAmount).
|
|
||||||
Where("expires_at > ?", time.Now())
|
|
||||||
query = applyLockAddressFilter(query, network, address)
|
|
||||||
err := query.Limit(1).Find(&lock).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if lock.ID <= 0 {
|
for _, lock := range locks {
|
||||||
return "", nil
|
if lockMatchesAmount(lock, amount) {
|
||||||
|
return lock.TradeId, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return lock.TradeId, nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
|
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
|
||||||
func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
|
func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
|
||||||
network = normalizeLockNetwork(network)
|
network = normalizeLockNetwork(network)
|
||||||
address = normalizeLockAddress(network, address)
|
address = normalizeLockAddress(network, address)
|
||||||
scaledAmount, amountText := normalizeLockAmount(amount)
|
precision := GetAmountPrecision()
|
||||||
|
scaledAmount, amountText := normalizeAmount(amount, precision)
|
||||||
normalizedToken := normalizeLockToken(token)
|
normalizedToken := normalizeLockToken(token)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
lock := &mdb.TransactionLock{
|
lock := &mdb.TransactionLock{
|
||||||
Network: network,
|
Network: network,
|
||||||
Address: address,
|
Address: address,
|
||||||
Token: normalizedToken,
|
Token: normalizedToken,
|
||||||
AmountScaled: scaledAmount,
|
AmountScaled: scaledAmount,
|
||||||
AmountText: amountText,
|
AmountText: amountText,
|
||||||
TradeId: tradeID,
|
AmountPrecision: precision,
|
||||||
ExpiresAt: now.Add(expirationTime),
|
TradeId: tradeID,
|
||||||
|
ExpiresAt: now.Add(expirationTime),
|
||||||
}
|
}
|
||||||
|
|
||||||
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
|
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
|
||||||
expiredQuery := tx.Where("network = ?", network).
|
expiredQuery := tx.Where("network = ?", network).
|
||||||
Where("token = ?", normalizedToken).
|
Where("token = ?", normalizedToken).
|
||||||
Where("amount_scaled = ?", scaledAmount).
|
|
||||||
Where("expires_at <= ?", now)
|
Where("expires_at <= ?", now)
|
||||||
expiredQuery = applyLockAddressFilter(expiredQuery, network, address)
|
expiredQuery = applyLockAddressFilter(expiredQuery, network, address)
|
||||||
if err := expiredQuery.Delete(&mdb.TransactionLock{}).Error; err != nil {
|
if err := expiredQuery.Delete(&mdb.TransactionLock{}).Error; err != nil {
|
||||||
@@ -314,6 +479,18 @@ func LockTransaction(network, address, token, tradeID string, amount float64, ex
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var existing []mdb.TransactionLock
|
||||||
|
if err := activeLocksForAddress(tx, network, address, normalizedToken, now).
|
||||||
|
Find(&existing).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
candidateAmount := lockAmountDecimal(*lock)
|
||||||
|
for _, item := range existing {
|
||||||
|
if lockAmountDecimal(item).Equal(candidateAmount) {
|
||||||
|
return ErrTransactionLocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock)
|
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(lock)
|
||||||
if result.Error != nil {
|
if result.Error != nil {
|
||||||
return result.Error
|
return result.Error
|
||||||
@@ -329,13 +506,21 @@ func LockTransaction(network, address, token, tradeID string, amount float64, ex
|
|||||||
func UnLockTransaction(network string, address string, token string, amount float64) error {
|
func UnLockTransaction(network string, address string, token string, amount float64) error {
|
||||||
network = normalizeLockNetwork(network)
|
network = normalizeLockNetwork(network)
|
||||||
address = normalizeLockAddress(network, address)
|
address = normalizeLockAddress(network, address)
|
||||||
scaledAmount, _ := normalizeLockAmount(amount)
|
var locks []mdb.TransactionLock
|
||||||
query := dao.RuntimeDB.
|
if err := activeLocksForAddress(dao.RuntimeDB, network, address, token, time.Now()).
|
||||||
Where("network = ?", network).
|
Find(&locks).Error; err != nil {
|
||||||
Where("token = ?", normalizeLockToken(token)).
|
return err
|
||||||
Where("amount_scaled = ?", scaledAmount)
|
}
|
||||||
query = applyLockAddressFilter(query, network, address)
|
ids := make([]uint64, 0, len(locks))
|
||||||
return query.Delete(&mdb.TransactionLock{}).Error
|
for _, lock := range locks {
|
||||||
|
if lockMatchesAmount(lock, amount) {
|
||||||
|
ids = append(ids, lock.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return dao.RuntimeDB.Where("id IN ?", ids).Delete(&mdb.TransactionLock{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func UnLockTransactionByTradeId(tradeID string) error {
|
func UnLockTransactionByTradeId(tradeID string) error {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
@@ -28,25 +29,40 @@ type AddressDailyStat struct {
|
|||||||
ActualAmount float64 `json:"actual_amount"`
|
ActualAmount float64 `json:"actual_amount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func statsBucketExpr(column string, hourly bool) (string, error) {
|
||||||
|
dialect := "sqlite"
|
||||||
|
if dao.Mdb != nil && dao.Mdb.Dialector != nil {
|
||||||
|
dialect = dao.Mdb.Dialector.Name()
|
||||||
|
}
|
||||||
|
return statsBucketExprForDialect(dialect, column, hourly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func statsBucketExprForDialect(dialect, column string, hourly bool) (string, error) {
|
||||||
|
switch dialect {
|
||||||
|
case "sqlite", "":
|
||||||
|
// modernc SQLite stores time.Time as Go's time.String() by default.
|
||||||
|
// Prefix buckets work for that format and SQLite datetime text.
|
||||||
|
if hourly {
|
||||||
|
return fmt.Sprintf("replace(substr(%s, 1, 13), 'T', ' ') || ':00'", column), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("substr(%s, 1, 10)", column), nil
|
||||||
|
case "postgres":
|
||||||
|
if hourly {
|
||||||
|
return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD HH24:00')", column), nil
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("TO_CHAR(%s, 'YYYY-MM-DD')", column), nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported database dialect for dashboard stats: %s", dialect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// DailyOrderStats returns per-day counts/amounts within [start, end],
|
// DailyOrderStats returns per-day counts/amounts within [start, end],
|
||||||
// zero-filling any days in the range that have no orders.
|
// zero-filling any days in the range that have no orders.
|
||||||
// Only orders in StatusPaySuccess contribute to Amount sums — that's
|
// Only orders in StatusPaySuccess contribute to Amount sums — that's
|
||||||
// what the user means by "流水". Pending orders count toward OrderCount
|
// what the user means by "流水". Pending orders count toward OrderCount
|
||||||
// to keep成交率 computable.
|
// to keep成交率 computable.
|
||||||
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||||
var rows []DailyStat
|
rows, err := queryOrderStats(start, end, false)
|
||||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
|
||||||
Select(`DATE(created_at) AS day,
|
|
||||||
COUNT(*) AS order_count,
|
|
||||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
|
||||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
|
||||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
|
||||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
|
||||||
Where("created_at >= ?", start).
|
|
||||||
Where("created_at <= ?", end).
|
|
||||||
Group("DATE(created_at)").
|
|
||||||
Order("day ASC").
|
|
||||||
Scan(&rows).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -56,25 +72,34 @@ func DailyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
|||||||
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
|
// HourlyOrderStats returns per-hour counts/amounts within [start, end],
|
||||||
// zero-filling any hours that have no orders.
|
// zero-filling any hours that have no orders.
|
||||||
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
func HourlyOrderStats(start, end time.Time) ([]DailyStat, error) {
|
||||||
var rows []DailyStat
|
rows, err := queryOrderStats(start, end, true)
|
||||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
|
||||||
Select(`strftime('%Y-%m-%d %H:00', created_at) AS day,
|
|
||||||
COUNT(*) AS order_count,
|
|
||||||
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
|
||||||
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
|
||||||
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`,
|
|
||||||
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
|
||||||
Where("created_at >= ?", start).
|
|
||||||
Where("created_at <= ?", end).
|
|
||||||
Group("strftime('%Y-%m-%d %H:00', created_at)").
|
|
||||||
Order("day ASC").
|
|
||||||
Scan(&rows).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return fillHourlyStats(start, end, rows), nil
|
return fillHourlyStats(start, end, rows), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queryOrderStats(start, end time.Time, hourly bool) ([]DailyStat, error) {
|
||||||
|
var rows []DailyStat
|
||||||
|
bucket, err := statsBucketExpr("created_at", hourly)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Select(fmt.Sprintf(`%s AS day,
|
||||||
|
COUNT(*) AS order_count,
|
||||||
|
SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) AS success_count,
|
||||||
|
SUM(CASE WHEN status = ? THEN amount ELSE 0 END) AS total_amount,
|
||||||
|
SUM(CASE WHEN status = ? THEN actual_amount ELSE 0 END) AS actual_amount`, bucket),
|
||||||
|
mdb.StatusPaySuccess, mdb.StatusPaySuccess, mdb.StatusPaySuccess).
|
||||||
|
Where("created_at >= ?", start).
|
||||||
|
Where("created_at <= ?", end).
|
||||||
|
Group(bucket).
|
||||||
|
Order("day ASC").
|
||||||
|
Scan(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
// fillDailyStats generates a zero-filled slice covering every calendar
|
// fillDailyStats generates a zero-filled slice covering every calendar
|
||||||
// day in [start, end]. Days present in rows overwrite the zero entries.
|
// day in [start, end]. Days present in rows overwrite the zero entries.
|
||||||
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
func fillDailyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
||||||
@@ -122,15 +147,7 @@ func fillHourlyStats(start, end time.Time, rows []DailyStat) []DailyStat {
|
|||||||
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
|
// DailyAssetByAddress groups paid actual_amount by (day, receive_address)
|
||||||
// for the per-address stacked chart, zero-filling missing days.
|
// for the per-address stacked chart, zero-filling missing days.
|
||||||
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||||
var rows []AddressDailyStat
|
rows, err := queryAssetByAddress(start, end, false)
|
||||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
|
||||||
Select("DATE(created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
|
||||||
Where("status = ?", mdb.StatusPaySuccess).
|
|
||||||
Where("created_at >= ?", start).
|
|
||||||
Where("created_at <= ?", end).
|
|
||||||
Group("DATE(created_at), receive_address").
|
|
||||||
Order("day ASC").
|
|
||||||
Scan(&rows).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -140,21 +157,33 @@ func DailyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
|||||||
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
|
// HourlyAssetByAddress groups paid actual_amount by (hour, receive_address)
|
||||||
// for the per-address stacked chart, zero-filling missing hours.
|
// for the per-address stacked chart, zero-filling missing hours.
|
||||||
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
func HourlyAssetByAddress(start, end time.Time) ([]AddressDailyStat, error) {
|
||||||
var rows []AddressDailyStat
|
rows, err := queryAssetByAddress(start, end, true)
|
||||||
err := dao.Mdb.Model(&mdb.Orders{}).
|
|
||||||
Select("strftime('%Y-%m-%d %H:00', created_at) AS day, receive_address AS address, SUM(actual_amount) AS actual_amount").
|
|
||||||
Where("status = ?", mdb.StatusPaySuccess).
|
|
||||||
Where("created_at >= ?", start).
|
|
||||||
Where("created_at <= ?", end).
|
|
||||||
Group("strftime('%Y-%m-%d %H:00', created_at), receive_address").
|
|
||||||
Order("day ASC").
|
|
||||||
Scan(&rows).Error
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return fillAddressHourlyStats(start, end, rows), nil
|
return fillAddressHourlyStats(start, end, rows), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func queryAssetByAddress(start, end time.Time, hourly bool) ([]AddressDailyStat, error) {
|
||||||
|
var rows []AddressDailyStat
|
||||||
|
bucket, err := statsBucketExpr("created_at", hourly)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
err = dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Select(fmt.Sprintf(`%s AS day,
|
||||||
|
receive_address AS address,
|
||||||
|
SUM(actual_amount) AS actual_amount`, bucket)).
|
||||||
|
Where("status = ?", mdb.StatusPaySuccess).
|
||||||
|
Where("created_at >= ?", start).
|
||||||
|
Where("created_at <= ?", end).
|
||||||
|
Group(bucket).
|
||||||
|
Group("receive_address").
|
||||||
|
Order("day ASC, address ASC").
|
||||||
|
Scan(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
// fillAddressDailyStats zero-fills missing day×address combinations.
|
// fillAddressDailyStats zero-fills missing day×address combinations.
|
||||||
// All addresses that appear in rows are represented for every day in range.
|
// All addresses that appear in rows are represented for every day in range.
|
||||||
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
func fillAddressDailyStats(start, end time.Time, rows []AddressDailyStat) []AddressDailyStat {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
||||||
@@ -41,6 +43,101 @@ func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatsBucketExprForDialect(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dialect string
|
||||||
|
hourly bool
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "sqlite daily",
|
||||||
|
dialect: "sqlite",
|
||||||
|
want: "substr(created_at, 1, 10)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sqlite hourly",
|
||||||
|
dialect: "sqlite",
|
||||||
|
hourly: true,
|
||||||
|
want: "replace(substr(created_at, 1, 13), 'T', ' ') || ':00'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "postgres daily",
|
||||||
|
dialect: "postgres",
|
||||||
|
want: "TO_CHAR(created_at, 'YYYY-MM-DD')",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "postgres hourly",
|
||||||
|
dialect: "postgres",
|
||||||
|
hourly: true,
|
||||||
|
want: "TO_CHAR(created_at, 'YYYY-MM-DD HH24:00')",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := statsBucketExprForDialect(tt.dialect, "created_at", tt.hourly)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("statsBucketExprForDialect error: %v", err)
|
||||||
|
}
|
||||||
|
if got != tt.want {
|
||||||
|
t.Fatalf("bucket expr = %q, want %q", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatsBucketExprRejectsUnsupportedDialect(t *testing.T) {
|
||||||
|
if _, err := statsBucketExprForDialect("mysql", "created_at", false); err == nil {
|
||||||
|
t.Fatal("expected unsupported mysql dialect error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransactionLockPrecisionPreventsEquivalentAmountsOnly(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "2", mdb.SettingTypeInt); err != nil {
|
||||||
|
t.Fatalf("set precision 2: %v", err)
|
||||||
|
}
|
||||||
|
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-old", 1.23, time.Hour); err != nil {
|
||||||
|
t.Fatalf("lock old transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
|
||||||
|
t.Fatalf("set precision 4: %v", err)
|
||||||
|
}
|
||||||
|
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-equivalent", 1.2300, time.Hour); !errors.Is(err, ErrTransactionLocked) {
|
||||||
|
t.Fatalf("equivalent lock error = %v, want %v", err, ErrTransactionLocked)
|
||||||
|
}
|
||||||
|
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress001", "USDT", "trade-new", 1.2301, time.Hour); err != nil {
|
||||||
|
t.Fatalf("distinct precision lock: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransactionLockLookupUsesStoredPrecision(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
|
||||||
|
t.Fatalf("set precision 4: %v", err)
|
||||||
|
}
|
||||||
|
if err := LockTransaction(mdb.NetworkTron, "TPrecisionAddress002", "USDT", "trade-precise", 1.2345, time.Hour); err != nil {
|
||||||
|
t.Fatalf("lock precise transaction: %v", err)
|
||||||
|
}
|
||||||
|
if err := SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "2", mdb.SettingTypeInt); err != nil {
|
||||||
|
t.Fatalf("set precision 2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, "TPrecisionAddress002", "USDT", 1.2345)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup transaction lock: %v", err)
|
||||||
|
}
|
||||||
|
if gotTradeID != "trade-precise" {
|
||||||
|
t.Fatalf("trade id = %q, want trade-precise", gotTradeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
|
func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -60,3 +157,46 @@ func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
|
|||||||
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
|
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTonTransactionLockAddressUsesRawKey(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
bounceable := "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"
|
||||||
|
addr := address.MustParseAddr(bounceable)
|
||||||
|
nonBounce := addr.Bounce(false).String()
|
||||||
|
|
||||||
|
if err := LockTransaction(mdb.NetworkTon, bounceable, "TON", "trade-ton", 1.23, time.Hour); err != nil {
|
||||||
|
t.Fatalf("lock ton transaction: %v", err)
|
||||||
|
}
|
||||||
|
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, nonBounce, "TON", 1.23)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup ton lock: %v", err)
|
||||||
|
}
|
||||||
|
if gotTradeID != "trade-ton" {
|
||||||
|
t.Fatalf("ton lock lookup = %q, want trade-ton", gotTradeID)
|
||||||
|
}
|
||||||
|
gotTradeID, err = GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", 1.23)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup ton raw lock: %v", err)
|
||||||
|
}
|
||||||
|
if gotTradeID != "trade-ton" {
|
||||||
|
t.Fatalf("ton raw lock lookup = %q, want trade-ton", gotTradeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAptosTransactionLockAddressUsesCanonicalKey(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := LockTransaction(mdb.NetworkAptos, "0xA", "USDT", "trade-aptos", 1.23, time.Hour); err != nil {
|
||||||
|
t.Fatalf("lock aptos transaction: %v", err)
|
||||||
|
}
|
||||||
|
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkAptos, "a", "USDT", 1.23)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup aptos lock: %v", err)
|
||||||
|
}
|
||||||
|
if gotTradeID != "trade-aptos" {
|
||||||
|
t.Fatalf("aptos lock lookup = %q, want trade-aptos", gotTradeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetProviderOrderByTradeIDAndProvider(tradeID string, provider string) (*mdb.ProviderOrder, error) {
|
||||||
|
row := new(mdb.ProviderOrder)
|
||||||
|
err := dao.Mdb.Model(row).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("provider = ?", provider).
|
||||||
|
Limit(1).
|
||||||
|
Find(row).Error
|
||||||
|
return row, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreateProviderOrderWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) error {
|
||||||
|
return tx.Model(row).Create(row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpsertProviderOrderCreatingWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) error {
|
||||||
|
return tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "trade_id"}, {Name: "provider"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"provider_order_id",
|
||||||
|
"pay_url",
|
||||||
|
"amount",
|
||||||
|
"coin",
|
||||||
|
"status",
|
||||||
|
"notify_raw",
|
||||||
|
"updated_at",
|
||||||
|
}),
|
||||||
|
}).Create(row).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateProviderOrderCreated(tradeID string, provider string, providerOrderID string, payURL string) error {
|
||||||
|
return UpdateProviderOrderCreatedWithTransaction(dao.Mdb, tradeID, provider, providerOrderID, payURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateProviderOrderCreatedWithTransaction(tx *gorm.DB, tradeID string, provider string, providerOrderID string, payURL string) error {
|
||||||
|
return tx.Model(&mdb.ProviderOrder{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("provider = ?", provider).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"provider_order_id": providerOrderID,
|
||||||
|
"pay_url": payURL,
|
||||||
|
"status": mdb.ProviderOrderStatusPending,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkProviderOrderFailed(tradeID string, provider string) error {
|
||||||
|
return dao.Mdb.Model(&mdb.ProviderOrder{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("provider = ?", provider).
|
||||||
|
Update("status", mdb.ProviderOrderStatusFailed).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func SaveProviderOrderNotify(tradeID string, provider string, notifyRaw string) error {
|
||||||
|
return dao.Mdb.Model(&mdb.ProviderOrder{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("provider = ?", provider).
|
||||||
|
Update("notify_raw", notifyRaw).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkProviderOrderPaid(tradeID string, provider string, notifyRaw string) error {
|
||||||
|
return dao.Mdb.Model(&mdb.ProviderOrder{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("provider = ?", provider).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": mdb.ProviderOrderStatusPaid,
|
||||||
|
"notify_raw": notifyRaw,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkProviderOrderExpired(tradeID string, provider string) error {
|
||||||
|
return dao.Mdb.Model(&mdb.ProviderOrder{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("provider = ?", provider).
|
||||||
|
Where("status IN ?", []string{mdb.ProviderOrderStatusCreating, mdb.ProviderOrderStatusPending}).
|
||||||
|
Update("status", mdb.ProviderOrderStatusExpired).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSubOrderByTokenPayProvider(parentTradeID string, token string, payProvider string) (*mdb.Orders, error) {
|
||||||
|
order := new(mdb.Orders)
|
||||||
|
err := dao.Mdb.Model(order).
|
||||||
|
Where("parent_trade_id = ?", parentTradeID).
|
||||||
|
Where("token = ?", token).
|
||||||
|
Where("pay_provider = ?", payProvider).
|
||||||
|
Where("status = ?", mdb.StatusWaitPay).
|
||||||
|
Limit(1).
|
||||||
|
Find(order).Error
|
||||||
|
return order, err
|
||||||
|
}
|
||||||
@@ -1,14 +1,44 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
RpcFailoverThreshold = 3
|
||||||
|
RpcFailoverCooldown = 60 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
var gRpcFailover = struct {
|
||||||
|
sync.Mutex
|
||||||
|
failures map[uint64]int
|
||||||
|
cooldownUntil map[uint64]time.Time
|
||||||
|
}{
|
||||||
|
failures: make(map[uint64]int),
|
||||||
|
cooldownUntil: make(map[uint64]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeRpcNodePurpose(purpose string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(purpose)) {
|
||||||
|
case mdb.RpcNodePurposeManualVerify:
|
||||||
|
return mdb.RpcNodePurposeManualVerify
|
||||||
|
case mdb.RpcNodePurposeBoth:
|
||||||
|
return mdb.RpcNodePurposeBoth
|
||||||
|
default:
|
||||||
|
return mdb.RpcNodePurposeGeneral
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ListRpcNodes returns rows optionally filtered by network.
|
// ListRpcNodes returns rows optionally filtered by network.
|
||||||
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
||||||
var rows []mdb.RpcNode
|
var rows []mdb.RpcNode
|
||||||
@@ -20,6 +50,84 @@ func ListRpcNodes(network string) ([]mdb.RpcNode, error) {
|
|||||||
return rows, err
|
return rows, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListRpcNodesForHealth returns nodes that should be probed by the periodic
|
||||||
|
// health job. Manual verification nodes are intentionally excluded because
|
||||||
|
// they may be paid/high-limit endpoints that should only be used on demand.
|
||||||
|
func ListRpcNodesForHealth() ([]mdb.RpcNode, error) {
|
||||||
|
var rows []mdb.RpcNode
|
||||||
|
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||||
|
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
|
||||||
|
Order("id ASC").
|
||||||
|
Find(&rows).Error
|
||||||
|
return rows, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListGeneralRpcCandidates returns enabled non-manual nodes for ordinary
|
||||||
|
// scanners/listeners. Down nodes are skipped; ok nodes are ordered before
|
||||||
|
// unknown/bootstrap nodes.
|
||||||
|
func ListGeneralRpcCandidates(network, nodeType string) ([]mdb.RpcNode, error) {
|
||||||
|
var rows []mdb.RpcNode
|
||||||
|
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||||
|
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||||
|
Where("type = ?", strings.ToLower(strings.TrimSpace(nodeType))).
|
||||||
|
Where("enabled = ?", true).
|
||||||
|
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
|
||||||
|
Where("(status IN ? OR status = '' OR status IS NULL)", []string{mdb.RpcNodeStatusOk, mdb.RpcNodeStatusUnknown}).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets := make([][]mdb.RpcNode, 2)
|
||||||
|
for _, row := range rows {
|
||||||
|
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
|
||||||
|
if row.Status == mdb.RpcNodeStatusOk {
|
||||||
|
buckets[0] = append(buckets[0], row)
|
||||||
|
} else {
|
||||||
|
buckets[1] = append(buckets[1], row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]mdb.RpcNode, 0, len(rows))
|
||||||
|
for _, bucket := range buckets {
|
||||||
|
sortRpcNodes(bucket)
|
||||||
|
out = append(out, bucket...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func SelectGeneralRpcNode(network, nodeType string, excludeIDs ...uint64) (*mdb.RpcNode, error) {
|
||||||
|
rows, err := ListGeneralRpcCandidates(network, nodeType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
excluded := make(map[uint64]struct{}, len(excludeIDs))
|
||||||
|
for _, id := range excludeIDs {
|
||||||
|
if id > 0 {
|
||||||
|
excluded[id] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
if _, ok := excluded[rows[i].ID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if IsRpcNodeCoolingDown(rows[i].ID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return &rows[i], nil
|
||||||
|
}
|
||||||
|
if len(excluded) == 0 {
|
||||||
|
for i := range rows {
|
||||||
|
return &rows[i], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRpcNodeByID fetches one row.
|
// GetRpcNodeByID fetches one row.
|
||||||
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
||||||
row := new(mdb.RpcNode)
|
row := new(mdb.RpcNode)
|
||||||
@@ -29,6 +137,7 @@ func GetRpcNodeByID(id uint64) (*mdb.RpcNode, error) {
|
|||||||
|
|
||||||
// CreateRpcNode inserts a row.
|
// CreateRpcNode inserts a row.
|
||||||
func CreateRpcNode(row *mdb.RpcNode) error {
|
func CreateRpcNode(row *mdb.RpcNode) error {
|
||||||
|
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
|
||||||
return dao.Mdb.Create(row).Error
|
return dao.Mdb.Create(row).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +146,9 @@ func UpdateRpcNodeFields(id uint64, fields map[string]interface{}) error {
|
|||||||
if len(fields) == 0 {
|
if len(fields) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if purpose, ok := fields["purpose"].(string); ok {
|
||||||
|
fields["purpose"] = NormalizeRpcNodePurpose(purpose)
|
||||||
|
}
|
||||||
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
|
return dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", id).Updates(fields).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +160,15 @@ func DeleteRpcNodeByID(id uint64) error {
|
|||||||
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
|
// SelectRpcNode picks a healthy RPC endpoint for a (network, type) pair.
|
||||||
// Strategy: weighted random among rows where enabled=true AND status=ok.
|
// Strategy: weighted random among rows where enabled=true AND status=ok.
|
||||||
// Falls back to enabled rows with status=unknown when no health check has
|
// Falls back to enabled rows with status=unknown when no health check has
|
||||||
// run yet. Explicitly down rows are not selected.
|
// run yet. Explicitly down rows are not selected. Manual verification nodes
|
||||||
|
// are excluded so paid/manual-only RPCs are never used by scanners/listeners.
|
||||||
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
||||||
var rows []mdb.RpcNode
|
var rows []mdb.RpcNode
|
||||||
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||||
Where("network = ?", strings.ToLower(network)).
|
Where("network = ?", strings.ToLower(network)).
|
||||||
Where("type = ?", strings.ToLower(nodeType)).
|
Where("type = ?", strings.ToLower(nodeType)).
|
||||||
Where("enabled = ?", true).
|
Where("enabled = ?", true).
|
||||||
|
Where("(purpose IN ? OR purpose = '' OR purpose IS NULL)", []string{mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth}).
|
||||||
Find(&rows).Error
|
Find(&rows).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -82,6 +196,144 @@ func SelectRpcNode(network, nodeType string) (*mdb.RpcNode, error) {
|
|||||||
return pickWeighted(candidates), nil
|
return pickWeighted(candidates), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListManualPaymentRpcCandidates returns enabled RPC nodes for manual payment
|
||||||
|
// verification. Ordinary RPCs are tried first to avoid paid endpoint usage;
|
||||||
|
// manual_verify nodes are used only as a fallback.
|
||||||
|
func ListManualPaymentRpcCandidates(network, nodeType string) ([]mdb.RpcNode, error) {
|
||||||
|
var rows []mdb.RpcNode
|
||||||
|
err := dao.Mdb.Model(&mdb.RpcNode{}).
|
||||||
|
Where("network = ?", strings.ToLower(strings.TrimSpace(network))).
|
||||||
|
Where("type = ?", strings.ToLower(strings.TrimSpace(nodeType))).
|
||||||
|
Where("enabled = ?", true).
|
||||||
|
Where("(status IN ? OR status = '' OR status IS NULL)", []string{mdb.RpcNodeStatusOk, mdb.RpcNodeStatusUnknown}).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets := make([][]mdb.RpcNode, 4)
|
||||||
|
for _, row := range rows {
|
||||||
|
row.Purpose = NormalizeRpcNodePurpose(row.Purpose)
|
||||||
|
switch row.Purpose {
|
||||||
|
case mdb.RpcNodePurposeGeneral, mdb.RpcNodePurposeBoth:
|
||||||
|
if row.Status == mdb.RpcNodeStatusOk {
|
||||||
|
buckets[0] = append(buckets[0], row)
|
||||||
|
} else {
|
||||||
|
buckets[1] = append(buckets[1], row)
|
||||||
|
}
|
||||||
|
case mdb.RpcNodePurposeManualVerify:
|
||||||
|
if row.Status == mdb.RpcNodeStatusOk {
|
||||||
|
buckets[2] = append(buckets[2], row)
|
||||||
|
} else {
|
||||||
|
buckets[3] = append(buckets[3], row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]mdb.RpcNode, 0, len(rows))
|
||||||
|
for _, bucket := range buckets {
|
||||||
|
sortRpcNodes(bucket)
|
||||||
|
out = append(out, bucket...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortRpcNodes(rows []mdb.RpcNode) {
|
||||||
|
sort.SliceStable(rows, func(i, j int) bool {
|
||||||
|
if rows[i].Weight != rows[j].Weight {
|
||||||
|
return rows[i].Weight > rows[j].Weight
|
||||||
|
}
|
||||||
|
return rows[i].ID < rows[j].ID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecordRpcNodeSuccess(nodeID uint64) {
|
||||||
|
if nodeID == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gRpcFailover.Lock()
|
||||||
|
defer gRpcFailover.Unlock()
|
||||||
|
delete(gRpcFailover.failures, nodeID)
|
||||||
|
delete(gRpcFailover.cooldownUntil, nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func RecordRpcNodeFailure(nodeID uint64) (int, bool) {
|
||||||
|
if nodeID == 0 {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
gRpcFailover.Lock()
|
||||||
|
defer gRpcFailover.Unlock()
|
||||||
|
if until, ok := gRpcFailover.cooldownUntil[nodeID]; ok {
|
||||||
|
if now.Before(until) {
|
||||||
|
return gRpcFailover.failures[nodeID], true
|
||||||
|
}
|
||||||
|
delete(gRpcFailover.cooldownUntil, nodeID)
|
||||||
|
delete(gRpcFailover.failures, nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
gRpcFailover.failures[nodeID]++
|
||||||
|
failures := gRpcFailover.failures[nodeID]
|
||||||
|
if failures >= RpcFailoverThreshold {
|
||||||
|
gRpcFailover.failures[nodeID] = 0
|
||||||
|
gRpcFailover.cooldownUntil[nodeID] = now.Add(RpcFailoverCooldown)
|
||||||
|
return failures, true
|
||||||
|
}
|
||||||
|
return failures, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsRpcNodeCoolingDown(nodeID uint64) bool {
|
||||||
|
if nodeID == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
gRpcFailover.Lock()
|
||||||
|
defer gRpcFailover.Unlock()
|
||||||
|
until, ok := gRpcFailover.cooldownUntil[nodeID]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if now.Before(until) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
delete(gRpcFailover.cooldownUntil, nodeID)
|
||||||
|
delete(gRpcFailover.failures, nodeID)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func RpcNodeLogLabel(node mdb.RpcNode) string {
|
||||||
|
return fmt.Sprintf("id=%d network=%s type=%s endpoint=%s", node.ID, node.Network, node.Type, safeRpcEndpoint(node.Url))
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeRpcEndpoint(raw string) string {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(raw)
|
||||||
|
if err == nil && parsed.Host != "" {
|
||||||
|
if parsed.Scheme != "" {
|
||||||
|
return parsed.Scheme + "://" + parsed.Host
|
||||||
|
}
|
||||||
|
return parsed.Host
|
||||||
|
}
|
||||||
|
cut := strings.IndexAny(raw, "/?")
|
||||||
|
if cut >= 0 {
|
||||||
|
return raw[:cut]
|
||||||
|
}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResetRpcFailoverForTest() {
|
||||||
|
gRpcFailover.Lock()
|
||||||
|
defer gRpcFailover.Unlock()
|
||||||
|
gRpcFailover.failures = make(map[uint64]int)
|
||||||
|
gRpcFailover.cooldownUntil = make(map[uint64]time.Time)
|
||||||
|
}
|
||||||
|
|
||||||
// pickWeighted chooses one row using the Weight column. Weights < 1 are
|
// pickWeighted chooses one row using the Weight column. Weights < 1 are
|
||||||
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
|
// coerced to 1 so an admin-zeroed row still has a chance (use Enabled=false
|
||||||
// to truly disable).
|
// to truly disable).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
@@ -99,3 +100,306 @@ func TestSelectRpcNodeIgnoresDownRows(t *testing.T) {
|
|||||||
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
|
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelectRpcNodeExcludesManualVerifyRows(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Url: "https://manual.example.com",
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Weight: 100,
|
||||||
|
Enabled: true,
|
||||||
|
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed manual rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Url: "https://general.example.com",
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Weight: 1,
|
||||||
|
Enabled: true,
|
||||||
|
Purpose: mdb.RpcNodePurposeGeneral,
|
||||||
|
Status: mdb.RpcNodeStatusUnknown,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed general rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SelectRpcNode(): %v", err)
|
||||||
|
}
|
||||||
|
if got == nil || got.Url != "https://general.example.com" {
|
||||||
|
t.Fatalf("SelectRpcNode() = %#v, want general row", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectRpcNodePurposeFilterAppliesToAnyNodeType(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
const nodeType = "grpc"
|
||||||
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Url: "grpc://manual.example.com",
|
||||||
|
Type: nodeType,
|
||||||
|
Weight: 100,
|
||||||
|
Enabled: true,
|
||||||
|
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed manual rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Url: "grpc://general.example.com",
|
||||||
|
Type: nodeType,
|
||||||
|
Weight: 1,
|
||||||
|
Enabled: true,
|
||||||
|
Purpose: mdb.RpcNodePurposeGeneral,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed general rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := SelectRpcNode(mdb.NetworkTron, nodeType)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SelectRpcNode(): %v", err)
|
||||||
|
}
|
||||||
|
if got == nil || got.Url != "grpc://general.example.com" {
|
||||||
|
t.Fatalf("SelectRpcNode() = %#v, want general grpc row", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectRpcNodeManualVerifyOnlyReturnsNil(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Url: "https://manual-only.example.com",
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Weight: 1,
|
||||||
|
Enabled: true,
|
||||||
|
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed manual rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SelectRpcNode(): %v", err)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("SelectRpcNode() = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListManualPaymentRpcCandidatesGeneralBeforeManualVerify(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://manual-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://general-unknown.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://general-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://manual-unknown.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://down.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusDown},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListManualPaymentRpcCandidates(): %v", err)
|
||||||
|
}
|
||||||
|
var urls []string
|
||||||
|
for _, row := range got {
|
||||||
|
urls = append(urls, row.Url)
|
||||||
|
}
|
||||||
|
want := []string{
|
||||||
|
"https://general-ok.example.com",
|
||||||
|
"https://general-unknown.example.com",
|
||||||
|
"https://manual-ok.example.com",
|
||||||
|
"https://manual-unknown.example.com",
|
||||||
|
}
|
||||||
|
if len(urls) != len(want) {
|
||||||
|
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if urls[i] != want[i] {
|
||||||
|
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListManualPaymentRpcCandidatesAllowsManualVerifyFallbackOnly(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://manual-ok.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://manual-down.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusDown},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ListManualPaymentRpcCandidates(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListManualPaymentRpcCandidates(): %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].Url != "https://manual-ok.example.com" {
|
||||||
|
t.Fatalf("candidate rows = %#v, want only manual ok fallback", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectGeneralRpcNodeSkipsCoolingNode(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
ResetRpcFailoverForTest()
|
||||||
|
t.Cleanup(ResetRpcFailoverForTest)
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||||
|
}
|
||||||
|
if first == nil || first.Url != "https://primary.example.com" {
|
||||||
|
t.Fatalf("first node = %#v, want primary", first)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < RpcFailoverThreshold; i++ {
|
||||||
|
RecordRpcNodeFailure(first.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||||
|
}
|
||||||
|
if got == nil || got.Url != "https://backup.example.com" {
|
||||||
|
t.Fatalf("node after cooldown = %#v, want backup", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectGeneralRpcNodeFallsBackWhenAllNodesCooling(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
ResetRpcFailoverForTest()
|
||||||
|
t.Cleanup(ResetRpcFailoverForTest)
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
for n := 0; n < RpcFailoverThreshold; n++ {
|
||||||
|
RecordRpcNodeFailure(rows[i].ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||||
|
}
|
||||||
|
if got == nil || got.Url != "https://primary.example.com" {
|
||||||
|
t.Fatalf("SelectGeneralRpcNode() = %#v, want primary when every node is cooling", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectGeneralRpcNodeReturnsNilWhenExcludedAlternatesCooling(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
ResetRpcFailoverForTest()
|
||||||
|
t.Cleanup(ResetRpcFailoverForTest)
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://primary.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://backup.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
for i := 0; i < RpcFailoverThreshold; i++ {
|
||||||
|
RecordRpcNodeFailure(rows[1].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := SelectGeneralRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp, rows[0].ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SelectGeneralRpcNode(): %v", err)
|
||||||
|
}
|
||||||
|
if got != nil {
|
||||||
|
t.Fatalf("SelectGeneralRpcNode() = %#v, want nil when only alternate is cooling", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordRpcNodeSuccessClearsCooldown(t *testing.T) {
|
||||||
|
ResetRpcFailoverForTest()
|
||||||
|
t.Cleanup(ResetRpcFailoverForTest)
|
||||||
|
|
||||||
|
const nodeID uint64 = 42
|
||||||
|
for i := 0; i < RpcFailoverThreshold; i++ {
|
||||||
|
RecordRpcNodeFailure(nodeID)
|
||||||
|
}
|
||||||
|
if !IsRpcNodeCoolingDown(nodeID) {
|
||||||
|
t.Fatal("node should be cooling after threshold")
|
||||||
|
}
|
||||||
|
RecordRpcNodeSuccess(nodeID)
|
||||||
|
if IsRpcNodeCoolingDown(nodeID) {
|
||||||
|
t.Fatal("node should not be cooling after success")
|
||||||
|
}
|
||||||
|
failures, cooling := RecordRpcNodeFailure(nodeID)
|
||||||
|
if cooling || failures != 1 {
|
||||||
|
t.Fatalf("failure state after success = failures=%d cooling=%v, want failures=1 cooling=false", failures, cooling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRpcNodeLogLabelHidesPathAndQuery(t *testing.T) {
|
||||||
|
label := RpcNodeLogLabel(mdb.RpcNode{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 7},
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Type: mdb.RpcNodeTypeWs,
|
||||||
|
Url: "wss://rpc.example.com/v3/secret-key?token=secret",
|
||||||
|
})
|
||||||
|
if strings.Contains(label, "secret") || strings.Contains(label, "/v3/") {
|
||||||
|
t.Fatalf("RpcNodeLogLabel() leaked sensitive URL parts: %s", label)
|
||||||
|
}
|
||||||
|
if !strings.Contains(label, "wss://rpc.example.com") {
|
||||||
|
t.Fatalf("RpcNodeLogLabel() = %s, want host", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListRpcNodesForHealthExcludesManualVerify(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://general.example.com", Type: mdb.RpcNodeTypeHttp, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://manual.example.com", Type: mdb.RpcNodeTypeHttp, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
got, err := ListRpcNodesForHealth()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListRpcNodesForHealth(): %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || got[0].Url != "https://general.example.com" {
|
||||||
|
t.Fatalf("ListRpcNodesForHealth() = %#v, want only general row", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RpcRuntimeStatsSnapshot is an in-process snapshot of RPC activity for one
|
||||||
|
// chain. It is intentionally not persisted; restarting the process resets it.
|
||||||
|
type RpcRuntimeStatsSnapshot struct {
|
||||||
|
Network string
|
||||||
|
SuccessCount int64
|
||||||
|
FailureCount int64
|
||||||
|
LatestBlockHeight int64
|
||||||
|
LastSyncAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcRuntimeStatsCounter struct {
|
||||||
|
successCount int64
|
||||||
|
failureCount int64
|
||||||
|
latestBlockHeight int64
|
||||||
|
lastSyncAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var gRpcRuntimeStats = struct {
|
||||||
|
sync.RWMutex
|
||||||
|
byNetwork map[string]*rpcRuntimeStatsCounter
|
||||||
|
}{
|
||||||
|
byNetwork: make(map[string]*rpcRuntimeStatsCounter),
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRpcStatsNetwork(network string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(network))
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcStatsCounterLocked(network string) *rpcRuntimeStatsCounter {
|
||||||
|
counter := gRpcRuntimeStats.byNetwork[network]
|
||||||
|
if counter == nil {
|
||||||
|
counter = &rpcRuntimeStatsCounter{}
|
||||||
|
gRpcRuntimeStats.byNetwork[network] = counter
|
||||||
|
}
|
||||||
|
return counter
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordRpcSuccess records one successful application-level RPC operation.
|
||||||
|
func RecordRpcSuccess(network string) {
|
||||||
|
network = normalizeRpcStatsNetwork(network)
|
||||||
|
if network == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gRpcRuntimeStats.Lock()
|
||||||
|
defer gRpcRuntimeStats.Unlock()
|
||||||
|
counter := rpcStatsCounterLocked(network)
|
||||||
|
counter.successCount++
|
||||||
|
counter.lastSyncAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordRpcFailure records one failed application-level RPC operation.
|
||||||
|
func RecordRpcFailure(network string) {
|
||||||
|
network = normalizeRpcStatsNetwork(network)
|
||||||
|
if network == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gRpcRuntimeStats.Lock()
|
||||||
|
defer gRpcRuntimeStats.Unlock()
|
||||||
|
counter := rpcStatsCounterLocked(network)
|
||||||
|
counter.failureCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordRpcBlockHeight records the latest observed chain height.
|
||||||
|
func RecordRpcBlockHeight(network string, height int64) {
|
||||||
|
network = normalizeRpcStatsNetwork(network)
|
||||||
|
if network == "" || height <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gRpcRuntimeStats.Lock()
|
||||||
|
defer gRpcRuntimeStats.Unlock()
|
||||||
|
counter := rpcStatsCounterLocked(network)
|
||||||
|
counter.lastSyncAt = time.Now()
|
||||||
|
if height > counter.latestBlockHeight {
|
||||||
|
counter.latestBlockHeight = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotRpcRuntimeStats returns a copy of the current per-chain counters.
|
||||||
|
func SnapshotRpcRuntimeStats() map[string]RpcRuntimeStatsSnapshot {
|
||||||
|
gRpcRuntimeStats.RLock()
|
||||||
|
defer gRpcRuntimeStats.RUnlock()
|
||||||
|
out := make(map[string]RpcRuntimeStatsSnapshot, len(gRpcRuntimeStats.byNetwork))
|
||||||
|
for network, counter := range gRpcRuntimeStats.byNetwork {
|
||||||
|
out[network] = RpcRuntimeStatsSnapshot{
|
||||||
|
Network: network,
|
||||||
|
SuccessCount: counter.successCount,
|
||||||
|
FailureCount: counter.failureCount,
|
||||||
|
LatestBlockHeight: counter.latestBlockHeight,
|
||||||
|
LastSyncAt: counter.lastSyncAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetRpcRuntimeStatsForTest clears process-local stats for isolated tests.
|
||||||
|
func ResetRpcRuntimeStatsForTest() {
|
||||||
|
gRpcRuntimeStats.Lock()
|
||||||
|
defer gRpcRuntimeStats.Unlock()
|
||||||
|
gRpcRuntimeStats.byNetwork = make(map[string]*rpcRuntimeStatsCounter)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRpcRuntimeStatsRecordsCountsAndLatestHeight(t *testing.T) {
|
||||||
|
ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
RecordRpcSuccess(" TRON ")
|
||||||
|
RecordRpcSuccess("tron")
|
||||||
|
RecordRpcFailure("TRON")
|
||||||
|
RecordRpcBlockHeight("Tron", 100)
|
||||||
|
RecordRpcBlockHeight("tron", 90)
|
||||||
|
|
||||||
|
stats := SnapshotRpcRuntimeStats()
|
||||||
|
got, ok := stats["tron"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing tron stats: %#v", stats)
|
||||||
|
}
|
||||||
|
if got.Network != "tron" {
|
||||||
|
t.Fatalf("network = %q, want tron", got.Network)
|
||||||
|
}
|
||||||
|
if got.SuccessCount != 2 {
|
||||||
|
t.Fatalf("success count = %d, want 2", got.SuccessCount)
|
||||||
|
}
|
||||||
|
if got.FailureCount != 1 {
|
||||||
|
t.Fatalf("failure count = %d, want 1", got.FailureCount)
|
||||||
|
}
|
||||||
|
if got.LatestBlockHeight != 100 {
|
||||||
|
t.Fatalf("latest block height = %d, want 100", got.LatestBlockHeight)
|
||||||
|
}
|
||||||
|
if got.LastSyncAt.Before(start) {
|
||||||
|
t.Fatalf("last sync at = %s, want after %s", got.LastSyncAt, start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRpcRuntimeStatsIgnoresEmptyNetworkAndInvalidHeight(t *testing.T) {
|
||||||
|
ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
RecordRpcSuccess("")
|
||||||
|
RecordRpcFailure(" ")
|
||||||
|
RecordRpcBlockHeight("tron", 0)
|
||||||
|
|
||||||
|
stats := SnapshotRpcRuntimeStats()
|
||||||
|
if len(stats) != 0 {
|
||||||
|
t.Fatalf("stats = %#v, want empty", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRpcRuntimeStatsBlockHeightUpdatesLastSyncAt(t *testing.T) {
|
||||||
|
ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
RecordRpcBlockHeight("ethereum", 123)
|
||||||
|
|
||||||
|
stats := SnapshotRpcRuntimeStats()
|
||||||
|
got := stats["ethereum"]
|
||||||
|
if got.SuccessCount != 0 {
|
||||||
|
t.Fatalf("success count = %d, want 0", got.SuccessCount)
|
||||||
|
}
|
||||||
|
if got.LastSyncAt.Before(start) {
|
||||||
|
t.Fatalf("last sync at = %s, want after %s", got.LastSyncAt, start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetRpcRuntimeStatsForTest(t *testing.T) {
|
||||||
|
ResetRpcRuntimeStatsForTest()
|
||||||
|
RecordRpcSuccess("tron")
|
||||||
|
ResetRpcRuntimeStatsForTest()
|
||||||
|
|
||||||
|
stats := SnapshotRpcRuntimeStats()
|
||||||
|
if len(stats) != 0 {
|
||||||
|
t.Fatalf("stats = %#v, want empty after reset", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
@@ -118,15 +119,36 @@ func GetSettingBool(key string, fallback bool) bool {
|
|||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultAmountPrecision = 2
|
||||||
|
MinAmountPrecision = 2
|
||||||
|
MaxAmountPrecision = 6
|
||||||
|
)
|
||||||
|
|
||||||
|
// NormalizeAmountPrecision clamps external precision values to the supported
|
||||||
|
// range used by order creation and transaction matching.
|
||||||
|
func NormalizeAmountPrecision(precision int) int {
|
||||||
|
if precision < MinAmountPrecision || precision > MaxAmountPrecision {
|
||||||
|
return DefaultAmountPrecision
|
||||||
|
}
|
||||||
|
return precision
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetAmountPrecision() int {
|
||||||
|
return NormalizeAmountPrecision(GetSettingInt(mdb.SettingKeyAmountPrecision, DefaultAmountPrecision))
|
||||||
|
}
|
||||||
|
|
||||||
// SetSetting upserts a setting row and refreshes the cache entry.
|
// SetSetting upserts a setting row and refreshes the cache entry.
|
||||||
func SetSetting(group, key, value, valueType string) error {
|
func SetSetting(group, key, value, valueType string) error {
|
||||||
if valueType == "" {
|
if valueType == "" {
|
||||||
valueType = mdb.SettingTypeString
|
valueType = mdb.SettingTypeString
|
||||||
}
|
}
|
||||||
row := mdb.Setting{Group: group, Key: key, Value: value, Type: valueType}
|
row := mdb.Setting{Group: group, Key: key, Value: value, Type: valueType}
|
||||||
|
updates := clause.AssignmentColumns([]string{"group", "value", "type", "updated_at"})
|
||||||
|
updates = append(updates, clause.Assignment{Column: clause.Column{Name: "deleted_at"}, Value: nil})
|
||||||
err := dao.Mdb.Clauses(clause.OnConflict{
|
err := dao.Mdb.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "key"}},
|
Columns: []clause.Column{{Name: "key"}},
|
||||||
DoUpdates: clause.AssignmentColumns([]string{"group", "value", "type", "updated_at"}),
|
DoUpdates: updates,
|
||||||
}).Create(&row).Error
|
}).Create(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -157,6 +179,97 @@ var sensitiveSettingKeys = []string{
|
|||||||
mdb.SettingKeyInitAdminPasswordChanged,
|
mdb.SettingKeyInitAdminPasswordChanged,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getFirstNonEmptySetting(fallback string, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
value := strings.TrimSpace(GetSettingString(key, ""))
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBrandCashierName() string {
|
||||||
|
return getFirstNonEmptySetting("", mdb.SettingKeyBrandCheckoutName, mdb.SettingKeyBrandSiteName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBrandLogoURL() string {
|
||||||
|
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandLogoUrl, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBrandWebsiteTitle() string {
|
||||||
|
return getFirstNonEmptySetting("", mdb.SettingKeyBrandSiteTitle, mdb.SettingKeyBrandPageTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBrandSupportURL() string {
|
||||||
|
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandSupportUrl, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBrandBackgroundColor() string {
|
||||||
|
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandBackgroundColor, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetBrandBackgroundImageURL() string {
|
||||||
|
return strings.TrimSpace(GetSettingString(mdb.SettingKeyBrandBackgroundImageUrl, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// OkPay settings helpers keep the provider-specific defaults in one place so
|
||||||
|
// business logic does not need to repeat raw settings keys.
|
||||||
|
func GetOkPayEnabled() bool {
|
||||||
|
return GetSettingBool(mdb.SettingKeyOkPayEnabled, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOkPayShopID() string {
|
||||||
|
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayShopID, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOkPayShopToken() string {
|
||||||
|
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayShopToken, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOkPayAPIURL() string {
|
||||||
|
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayAPIURL, "https://api.okaypay.me/shop/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOkPayCallbackURL() string {
|
||||||
|
if configured := strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayCallbackURL, "")); configured != "" {
|
||||||
|
return configured
|
||||||
|
}
|
||||||
|
appURI := strings.TrimSpace(config.GetAppUri())
|
||||||
|
if appURI == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimRight(appURI, "/") + "/payments/okpay/v1/notify"
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOkPayReturnURL() string {
|
||||||
|
return strings.TrimSpace(GetSettingString(mdb.SettingKeyOkPayReturnURL, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOkPayTimeoutSeconds() int {
|
||||||
|
return GetSettingInt(mdb.SettingKeyOkPayTimeoutSeconds, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetOkPayAllowTokens() []string {
|
||||||
|
raw := GetSettingString(mdb.SettingKeyOkPayAllowTokens, "USDT,TRX")
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return []string{"USDT", "TRX"}
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
token := strings.ToUpper(strings.TrimSpace(part))
|
||||||
|
if token == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, token)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return []string{"USDT", "TRX"}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ListSettingsByGroup returns all rows for a given group (empty group = all),
|
// ListSettingsByGroup returns all rows for a given group (empty group = all),
|
||||||
// excluding any keys in sensitiveSettingKeys.
|
// excluding any keys in sensitiveSettingKeys.
|
||||||
func ListSettingsByGroup(group string) ([]mdb.Setting, error) {
|
func ListSettingsByGroup(group string) ([]mdb.Setting, error) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,10 +35,26 @@ func normalizeWalletAddressByNetwork(network, address string) string {
|
|||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeWalletAddressByNetworkE(network, address string) (string, error) {
|
||||||
|
network = normalizeWalletNetwork(network)
|
||||||
|
address = strings.TrimSpace(address)
|
||||||
|
if network == mdb.NetworkTon {
|
||||||
|
return addressutil.NormalizeTonAddress(address)
|
||||||
|
}
|
||||||
|
if network == mdb.NetworkAptos {
|
||||||
|
return addressutil.NormalizeMoveAddress(address)
|
||||||
|
}
|
||||||
|
return normalizeWalletAddressByNetwork(network, address), nil
|
||||||
|
}
|
||||||
|
|
||||||
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
||||||
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
||||||
network = normalizeWalletNetwork(network)
|
network = normalizeWalletNetwork(network)
|
||||||
address = normalizeWalletAddressByNetwork(network, address)
|
var err error
|
||||||
|
address, err = normalizeWalletAddressByNetworkE(network, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,9 +92,13 @@ func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, e
|
|||||||
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
|
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
|
||||||
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
|
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
|
||||||
network = normalizeWalletNetwork(network)
|
network = normalizeWalletNetwork(network)
|
||||||
address = normalizeWalletAddressByNetwork(network, address)
|
var err error
|
||||||
|
address, err = normalizeWalletAddressByNetworkE(network, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
walletAddress := new(mdb.WalletAddress)
|
walletAddress := new(mdb.WalletAddress)
|
||||||
err := dao.Mdb.Model(walletAddress).
|
err = dao.Mdb.Model(walletAddress).
|
||||||
Where("network = ?", network).
|
Where("network = ?", network).
|
||||||
Where("address = ?", address).
|
Where("address = ?", address).
|
||||||
Limit(1).Find(walletAddress).Error
|
Limit(1).Find(walletAddress).Error
|
||||||
@@ -126,6 +147,12 @@ func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, er
|
|||||||
for i := range list {
|
for i := range list {
|
||||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||||
}
|
}
|
||||||
|
} else if network == mdb.NetworkTon {
|
||||||
|
for i := range list {
|
||||||
|
if normalized, err := addressutil.NormalizeTonAddress(list[i].Address); err == nil {
|
||||||
|
list[i].Address = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
@@ -149,6 +176,12 @@ func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
|
|||||||
for i := range list {
|
for i := range list {
|
||||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||||
}
|
}
|
||||||
|
} else if network == mdb.NetworkTon {
|
||||||
|
for i := range list {
|
||||||
|
if normalized, err := addressutil.NormalizeTonAddress(list[i].Address); err == nil {
|
||||||
|
list[i].Address = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
||||||
@@ -81,3 +83,40 @@ func TestAddWalletAddressWithNetworkKeepsOriginalCaseForNonEvm(t *testing.T) {
|
|||||||
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
|
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAddWalletAddressWithNetworkNormalizesTonAddressVariants(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
bounceable := "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"
|
||||||
|
addr := address.MustParseAddr(bounceable)
|
||||||
|
expected := addr.Bounce(false).String()
|
||||||
|
|
||||||
|
row, err := AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add raw ton wallet: %v", err)
|
||||||
|
}
|
||||||
|
if row.Address != expected {
|
||||||
|
t.Fatalf("stored TON address = %q, want %q", row.Address, expected)
|
||||||
|
}
|
||||||
|
if _, err = AddWalletAddressWithNetwork(mdb.NetworkTon, bounceable); err != constant.WalletAddressAlreadyExists {
|
||||||
|
t.Fatalf("add equivalent ton wallet error = %v, want already exists", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddWalletAddressWithNetworkNormalizesMoveAddressVariants(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
want := "0x000000000000000000000000000000000000000000000000000000000000000a"
|
||||||
|
row, err := AddWalletAddressWithNetwork(mdb.NetworkAptos, " A ")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add aptos wallet: %v", err)
|
||||||
|
}
|
||||||
|
if row.Address != want {
|
||||||
|
t.Fatalf("stored Aptos address = %q, want %q", row.Address, want)
|
||||||
|
}
|
||||||
|
if _, err = AddWalletAddressWithNetwork(mdb.NetworkAptos, "0x0A"); err != constant.WalletAddressAlreadyExists {
|
||||||
|
t.Fatalf("add equivalent aptos wallet error = %v, want already exists", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,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"`
|
||||||
|
|||||||
+45
-10
@@ -1,19 +1,42 @@
|
|||||||
package mdb
|
package mdb
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StatusWaitPay = 1
|
StatusWaitPay = 1
|
||||||
StatusPaySuccess = 2
|
StatusPaySuccess = 2
|
||||||
StatusExpired = 3
|
StatusExpired = 3
|
||||||
|
// StatusWaitSelect is a placeholder order waiting for the payer to choose
|
||||||
|
// token+network or a hosted provider such as OkPay. It is not payable until
|
||||||
|
// switch-network moves it to StatusWaitPay.
|
||||||
|
StatusWaitSelect = 4
|
||||||
CallBackConfirmOk = 1
|
CallBackConfirmOk = 1
|
||||||
CallBackConfirmNo = 2
|
CallBackConfirmNo = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// PaymentTypeEpay is the normalized special payment_type value. Orders
|
||||||
|
// matching it case-insensitively use the legacy EPay callback shape.
|
||||||
PaymentTypeEpay = "Epay"
|
PaymentTypeEpay = "Epay"
|
||||||
|
// PaymentTypeGmpay is the normalized default for GMPay-created orders.
|
||||||
|
// It uses the default GMPay JSON callback shape.
|
||||||
|
PaymentTypeGmpay = "Gmpay"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// PaymentProviderOnChain means this concrete order record is settled by
|
||||||
|
// sending funds to a directly assigned wallet address on a supported chain.
|
||||||
|
PaymentProviderOnChain = "on_chain"
|
||||||
|
// PaymentProviderOkPay means this concrete order record is settled through
|
||||||
|
// the third-party OkayPay/OkPay hosted checkout flow.
|
||||||
|
//
|
||||||
|
// It can be used by a status=4 parent completed in place on first
|
||||||
|
// switch-network, or by a switch-network-created child order after the
|
||||||
|
// parent is already concrete. Merchant callback shape is still controlled by
|
||||||
|
// PaymentType, not by this provider value.
|
||||||
|
PaymentProviderOkPay = "okpay"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Orders struct {
|
type Orders struct {
|
||||||
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"T2026041612345678"`
|
TradeId string `gorm:"column:trade_id;uniqueIndex:orders_trade_id_uindex" json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
||||||
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
|
OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id" example:"ORD20260416001"`
|
||||||
ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"`
|
ParentTradeId string `gorm:"column:parent_trade_id;index:idx_orders_parent_trade_id;default:''" json:"parent_trade_id"`
|
||||||
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."`
|
BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id" example:"0xabc123..."`
|
||||||
@@ -23,17 +46,29 @@ type Orders struct {
|
|||||||
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
|
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
|
||||||
Token string `gorm:"column:token" json:"token" example:"USDT"`
|
Token string `gorm:"column:token" json:"token" example:"USDT"`
|
||||||
Network string `gorm:"column:network" json:"network" example:"tron"`
|
Network string `gorm:"column:network" json:"network" example:"tron"`
|
||||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
||||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" example:"1"`
|
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3,4" example:"1"`
|
||||||
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
|
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
|
||||||
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
|
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
|
||||||
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
|
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
|
||||||
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
|
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
|
||||||
// 回调确认状态 1=回调成功 2=未回调/回调失败
|
// 回调确认状态 1=回调成功 2=未回调/回调失败
|
||||||
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
|
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
|
||||||
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
|
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
|
||||||
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
|
// PaymentType is not a channel selector. "Epay" changes callback format
|
||||||
ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"`
|
// to legacy EPay case-insensitively; "Gmpay" uses GMPay JSON.
|
||||||
|
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
|
||||||
|
// PayProvider identifies how this specific order row is collected.
|
||||||
|
//
|
||||||
|
// Semantics:
|
||||||
|
// - chain parent orders and regular chain child orders use on_chain
|
||||||
|
// - third-party hosted checkout rows use their provider name
|
||||||
|
// (for example okpay)
|
||||||
|
//
|
||||||
|
// Existing rows default to on_chain for backward compatibility so upgrades
|
||||||
|
// can rely on AutoMigrate without rewriting old orders.
|
||||||
|
PayProvider string `gorm:"column:pay_provider;size:32;default:on_chain;index:orders_pay_provider_index" json:"pay_provider" example:"on_chain"`
|
||||||
|
ApiKeyID uint64 `gorm:"column:api_key_id;default:0;index:orders_api_key_id_index" json:"api_key_id" example:"1"`
|
||||||
// PayBySubId holds the primary-key ID of the sub-order that settled this parent order.
|
// PayBySubId holds the primary-key ID of the sub-order that settled this parent order.
|
||||||
// Zero when the parent order was paid directly (no sub-order involved).
|
// Zero when the parent order was paid directly (no sub-order involved).
|
||||||
PayBySubId uint64 `gorm:"column:pay_by_sub_id;default:0" json:"pay_by_sub_id" example:"0"`
|
PayBySubId uint64 `gorm:"column:pay_by_sub_id;default:0" json:"pay_by_sub_id" example:"0"`
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package mdb
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProviderOrderStatusCreating = "creating"
|
||||||
|
ProviderOrderStatusPending = "pending"
|
||||||
|
ProviderOrderStatusPaid = "paid"
|
||||||
|
ProviderOrderStatusFailed = "failed"
|
||||||
|
ProviderOrderStatusExpired = "expired"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProviderOrder stores provider-specific checkout data for one internal order
|
||||||
|
// row. For OkPay this can bind either to a status=4 parent completed in place
|
||||||
|
// or to a concrete child order created by later switch-network calls.
|
||||||
|
//
|
||||||
|
// Binding rules:
|
||||||
|
// - trade_id always points to orders.trade_id
|
||||||
|
// - the bound orders row should use the same pay_provider value
|
||||||
|
// - provider identifies which downstream checkout created the provider row
|
||||||
|
// - provider_order_id is the provider's own order number (for OkPay this is
|
||||||
|
// the returned order_id from /payLink)
|
||||||
|
// - pay_url is the hosted payment URL returned by the provider
|
||||||
|
//
|
||||||
|
// The main orders table remains the source of truth for merchant-facing state,
|
||||||
|
// while this table keeps provider-specific identifiers and callback payloads.
|
||||||
|
type ProviderOrder struct {
|
||||||
|
TradeId string `gorm:"column:trade_id;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:1;index:provider_orders_trade_id_index" json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
||||||
|
// Provider values should match the bound Orders.PayProvider value.
|
||||||
|
Provider string `gorm:"column:provider;size:32;not null;uniqueIndex:provider_orders_trade_id_provider_uindex,priority:2;index:provider_orders_provider_status_index,priority:1" json:"provider" example:"okpay"`
|
||||||
|
// ProviderOrderID is the downstream provider's own order identifier for the
|
||||||
|
// bound concrete order row.
|
||||||
|
ProviderOrderID string `gorm:"column:provider_order_id;size:128;not null;default:'';index:provider_orders_provider_order_id_index" json:"provider_order_id" example:"ac7b86615fdb137576ae35879f7ed844"`
|
||||||
|
// PayURL is the hosted checkout URL returned by the downstream provider for
|
||||||
|
// the bound concrete order row.
|
||||||
|
PayURL string `gorm:"column:pay_url;size:512;not null;default:''" json:"pay_url" example:"https://pay.example.com/checkout/abc123"`
|
||||||
|
// Amount/Coin mirror the exact payload submitted to the provider, which may
|
||||||
|
// be useful when validating callbacks and troubleshooting mismatches.
|
||||||
|
Amount float64 `gorm:"column:amount" json:"amount" example:"14.2857"`
|
||||||
|
Coin string `gorm:"column:coin;size:16;not null;default:''" json:"coin" example:"USDT"`
|
||||||
|
Status string `gorm:"column:status;size:32;not null;default:pending;index:provider_orders_provider_status_index,priority:2" json:"status" example:"pending"`
|
||||||
|
// NotifyRaw stores the original provider callback payload for auditing and
|
||||||
|
// replay/debug use. It is provider-specific and intentionally opaque here.
|
||||||
|
NotifyRaw string `gorm:"column:notify_raw;type:text" json:"notify_raw"`
|
||||||
|
BaseModel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProviderOrder) TableName() string {
|
||||||
|
return "provider_orders"
|
||||||
|
}
|
||||||
+19
-11
@@ -5,6 +5,7 @@ import "github.com/dromara/carbon/v2"
|
|||||||
const (
|
const (
|
||||||
RpcNodeTypeHttp = "http"
|
RpcNodeTypeHttp = "http"
|
||||||
RpcNodeTypeWs = "ws"
|
RpcNodeTypeWs = "ws"
|
||||||
|
RpcNodeTypeLite = "lite"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -13,19 +14,26 @@ const (
|
|||||||
RpcNodeStatusDown = "down"
|
RpcNodeStatusDown = "down"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RpcNode records one RPC endpoint for a network. Clients select a node
|
const (
|
||||||
// by weight among rows where Enabled=true AND Status=ok. A periodic
|
RpcNodePurposeGeneral = "general"
|
||||||
// task/rpc_health_job writes Status/LastLatencyMs/LastCheckedAt.
|
RpcNodePurposeManualVerify = "manual_verify"
|
||||||
|
RpcNodePurposeBoth = "both"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RpcNode records one RPC endpoint for a network. Runtime selection filters by
|
||||||
|
// Type, Status and Purpose so paid manual-verification nodes stay isolated.
|
||||||
type RpcNode struct {
|
type RpcNode struct {
|
||||||
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
|
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
|
||||||
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
|
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
|
||||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
// 连接类型 http=HTTP请求 ws=WebSocket长连接 lite=TON liteserver配置
|
||||||
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"`
|
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws,lite" example:"http"`
|
||||||
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
|
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
|
||||||
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
|
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
|
||||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||||
|
// 用途 general=通用 manual_verify=补单专用 both=通用+补单
|
||||||
|
Purpose string `gorm:"column:purpose;size:32;default:general;index:rpc_nodes_purpose_index" json:"purpose" enums:"general,manual_verify,both" example:"general"`
|
||||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||||
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
|
Status string `gorm:"column:status;size:16;default:unknown;index:rpc_nodes_network_status_index,priority:2" json:"status" enums:"unknown,ok,down" example:"ok"`
|
||||||
LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"`
|
LastLatencyMs int `gorm:"column:last_latency_ms;default:-1" json:"last_latency_ms" example:"120"`
|
||||||
LastCheckedAt carbon.Time `gorm:"column:last_checked_at" json:"last_checked_at" example:"2026-04-16 12:00:00"`
|
LastCheckedAt carbon.Time `gorm:"column:last_checked_at" json:"last_checked_at" example:"2026-04-16 12:00:00"`
|
||||||
BaseModel
|
BaseModel
|
||||||
|
|||||||
+35
-12
@@ -1,17 +1,19 @@
|
|||||||
package mdb
|
package mdb
|
||||||
|
|
||||||
// Setting stores non-credential runtime configuration as key/value pairs.
|
// Setting stores non-credential runtime configuration as key/value pairs.
|
||||||
// Groups: brand, rate, system, epay. Merchant credentials (pid + secret_key)
|
// Groups: brand, rate, system, epay, okpay. Merchant credentials (pid +
|
||||||
// live in the api_keys table; notification configs live in NotificationChannel.
|
// secret_key) live in the api_keys table; notification configs live in
|
||||||
// Default rows for the epay group are seeded on first startup (see
|
// NotificationChannel.
|
||||||
// model/dao/mdb_table_init.go seedDefaultSettings). All other groups start
|
// Default rows for the system/epay/okpay groups are seeded on first startup
|
||||||
// empty and fall back to hardcoded defaults until an admin sets them.
|
// (see model/dao/mdb_table_init.go seedDefaultSettings). Other groups start
|
||||||
// Only exception: system.jwt_secret is auto-generated on first startup.
|
// empty and fall back to hardcoded defaults until an admin sets them. The
|
||||||
|
// system.jwt_secret key is auto-generated on first startup.
|
||||||
const (
|
const (
|
||||||
SettingGroupBrand = "brand"
|
SettingGroupBrand = "brand"
|
||||||
SettingGroupRate = "rate"
|
SettingGroupRate = "rate"
|
||||||
SettingGroupSystem = "system"
|
SettingGroupSystem = "system"
|
||||||
SettingGroupEpay = "epay"
|
SettingGroupEpay = "epay"
|
||||||
|
SettingGroupOkPay = "okpay"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -28,12 +30,19 @@ const (
|
|||||||
SettingKeyInitAdminPasswordFetched = "system.init_admin_password_fetched"
|
SettingKeyInitAdminPasswordFetched = "system.init_admin_password_fetched"
|
||||||
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
|
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
|
||||||
SettingKeyOrderExpiration = "system.order_expiration_time"
|
SettingKeyOrderExpiration = "system.order_expiration_time"
|
||||||
|
SettingKeyAmountPrecision = "system.amount_precision"
|
||||||
|
SettingKeySystemLogLevel = "system.log_level"
|
||||||
|
SettingKeyBrandCheckoutName = "brand.checkout_name"
|
||||||
SettingKeyBrandSiteName = "brand.site_name"
|
SettingKeyBrandSiteName = "brand.site_name"
|
||||||
SettingKeyBrandLogoUrl = "brand.logo_url"
|
SettingKeyBrandLogoUrl = "brand.logo_url"
|
||||||
|
SettingKeyBrandSiteTitle = "brand.site_title"
|
||||||
SettingKeyBrandPageTitle = "brand.page_title"
|
SettingKeyBrandPageTitle = "brand.page_title"
|
||||||
|
SettingKeyBrandSuccessCopy = "brand.success_copy"
|
||||||
SettingKeyBrandPaySuccess = "brand.pay_success_text"
|
SettingKeyBrandPaySuccess = "brand.pay_success_text"
|
||||||
SettingKeyBrandSupportUrl = "brand.support_url"
|
SettingKeyBrandSupportUrl = "brand.support_url"
|
||||||
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate"
|
SettingKeyBrandBackgroundColor = "brand.background_color"
|
||||||
|
SettingKeyBrandBackgroundImageUrl = "brand.background_image_url"
|
||||||
|
SettingKeyRateForcedRateList = "rate.forced_rate_list"
|
||||||
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
SettingKeyRateAdjustPercent = "rate.adjust_percent"
|
||||||
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
SettingKeyRateOkxC2cEnabled = "rate.okx_c2c_enabled"
|
||||||
SettingKeyRateApiUrl = "rate.api_url"
|
SettingKeyRateApiUrl = "rate.api_url"
|
||||||
@@ -42,14 +51,28 @@ const (
|
|||||||
SettingKeyEpayDefaultToken = "epay.default_token"
|
SettingKeyEpayDefaultToken = "epay.default_token"
|
||||||
SettingKeyEpayDefaultCurrency = "epay.default_currency"
|
SettingKeyEpayDefaultCurrency = "epay.default_currency"
|
||||||
SettingKeyEpayDefaultNetwork = "epay.default_network"
|
SettingKeyEpayDefaultNetwork = "epay.default_network"
|
||||||
|
|
||||||
|
// OkPay hosted-checkout settings.
|
||||||
|
SettingKeyOkPayEnabled = "okpay.enabled"
|
||||||
|
SettingKeyOkPayShopID = "okpay.shop_id"
|
||||||
|
SettingKeyOkPayShopToken = "okpay.shop_token"
|
||||||
|
SettingKeyOkPayAPIURL = "okpay.api_url"
|
||||||
|
SettingKeyOkPayCallbackURL = "okpay.callback_url"
|
||||||
|
SettingKeyOkPayReturnURL = "okpay.return_url"
|
||||||
|
SettingKeyOkPayTimeoutSeconds = "okpay.timeout_seconds"
|
||||||
|
SettingKeyOkPayAllowTokens = "okpay.allow_tokens"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingDefaultSystemLogLevel = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Setting struct {
|
type Setting struct {
|
||||||
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system" example:"rate"`
|
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system,epay,okpay" example:"rate"`
|
||||||
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_usdt_rate"`
|
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_rate_list"`
|
||||||
Value string `gorm:"column:value;type:text" json:"value" example:"7.2"`
|
Value string `gorm:"column:value;type:text" json:"value" example:"{\"cny\":{\"usdt\":0.14635}}"`
|
||||||
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"string"`
|
Type string `gorm:"column:type;size:16;default:string" json:"type" enums:"string,int,bool,json" example:"json"`
|
||||||
Description string `gorm:"column:description;size:255" json:"description" example:"强制USDT汇率"`
|
Description string `gorm:"column:description;size:255" json:"description" example:"强制汇率列表"`
|
||||||
BaseModel
|
BaseModel
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,16 +3,17 @@ package mdb
|
|||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
type TransactionLock struct {
|
type TransactionLock struct {
|
||||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||||
Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
|
Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
|
||||||
Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
|
Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
|
||||||
Token string `gorm:"column:token;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:3" json:"token"`
|
Token string `gorm:"column:token;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:3" json:"token"`
|
||||||
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:4" json:"amount_scaled"`
|
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:4" json:"amount_scaled"`
|
||||||
AmountText string `gorm:"column:amount_text" json:"amount_text"`
|
AmountText string `gorm:"column:amount_text" json:"amount_text"`
|
||||||
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
|
AmountPrecision int `gorm:"column:amount_precision;default:2;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:5" json:"amount_precision"`
|
||||||
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
|
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
|
||||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *TransactionLock) TableName() string {
|
func (t *TransactionLock) TableName() string {
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ const (
|
|||||||
NetworkTron = "tron"
|
NetworkTron = "tron"
|
||||||
NetworkSolana = "solana"
|
NetworkSolana = "solana"
|
||||||
NetworkEthereum = "ethereum"
|
NetworkEthereum = "ethereum"
|
||||||
NetworkBsc = "bsc"
|
NetworkBsc = "binance"
|
||||||
NetworkPolygon = "polygon"
|
NetworkPolygon = "polygon"
|
||||||
NetworkPlasma = "plasma"
|
NetworkPlasma = "plasma"
|
||||||
|
NetworkTon = "ton"
|
||||||
|
NetworkAptos = "aptos"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import "github.com/gookit/validate"
|
|||||||
type CreateTransactionRequest struct {
|
type CreateTransactionRequest struct {
|
||||||
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
|
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
|
||||||
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
|
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
|
||||||
Token string `json:"token" form:"token" validate:"required" example:"usdt"` // 币种 如:usdt
|
Token string `json:"token" form:"token" example:"usdt"` // 币种 如:usdt、ton;可与 network 同时缺省创建占位订单
|
||||||
Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:tron
|
Network string `json:"network" form:"network" example:"tron"` // 网络 如:ton、tron、aptos;可与 token 同时缺省创建占位订单
|
||||||
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
|
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
|
||||||
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
|
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
|
||||||
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
|
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
|
||||||
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
|
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
|
||||||
Name string `json:"name" form:"name" example:"VIP月卡"`
|
Name string `json:"name" form:"name" example:"VIP月卡"`
|
||||||
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
|
// PaymentType is a compatibility flag, not a gateway selector. Only
|
||||||
|
// "Epay" (case-insensitive) switches callback format to legacy EPay;
|
||||||
|
// empty or any other value is stored as "Gmpay" and uses GMPay JSON.
|
||||||
|
// It is optional for GMPay, but must be included in the signature when sent.
|
||||||
|
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r CreateTransactionRequest) Translates() map[string]string {
|
func (r CreateTransactionRequest) Translates() map[string]string {
|
||||||
@@ -39,11 +43,22 @@ type OrderProcessingRequest struct {
|
|||||||
BlockTransactionId string
|
BlockTransactionId string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ManualPaymentRequest 手动提交交易 hash 补单
|
||||||
|
type ManualPaymentRequest struct {
|
||||||
|
BlockTransactionId string `json:"block_transaction_id" validate:"required" example:"0xabc123def456..."`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r ManualPaymentRequest) Translates() map[string]string {
|
||||||
|
return validate.MS{
|
||||||
|
"BlockTransactionId": "交易哈希",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SwitchNetworkRequest 切换支付网络
|
// SwitchNetworkRequest 切换支付网络
|
||||||
type SwitchNetworkRequest struct {
|
type SwitchNetworkRequest struct {
|
||||||
TradeId string `json:"trade_id" validate:"required" example:"T2026041612345678"`
|
TradeId string `json:"trade_id" validate:"required" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"`
|
||||||
Token string `json:"token" validate:"required" example:"USDT"`
|
Token string `json:"token" validate:"required" example:"USDT"`
|
||||||
Network string `json:"network" validate:"required" example:"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 {
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ package response
|
|||||||
// Used for swagger documentation only — the actual response is built
|
// Used for swagger documentation only — the actual response is built
|
||||||
// by util/http.Resp.
|
// by util/http.Resp.
|
||||||
type ApiResponse struct {
|
type ApiResponse struct {
|
||||||
StatusCode int `json:"status_code" example:"200"`
|
// Stable errno used by the frontend for i18n. Success is 200; failures use codes from util/constant.Errno.
|
||||||
Message string `json:"message" example:"success"`
|
StatusCode int `json:"status_code" example:"200"`
|
||||||
Data interface{} `json:"data"`
|
// Default server-side errno text. Frontend should prefer status_code for display translation.
|
||||||
RequestID string `json:"request_id" example:"req-123456"`
|
Message string `json:"message" example:"success"`
|
||||||
|
Data interface{} `json:"data"`
|
||||||
|
// Request id echoed from X-Request-ID when present.
|
||||||
|
RequestID string `json:"request_id" example:"req-123456"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,41 +2,42 @@ package response
|
|||||||
|
|
||||||
// CreateTransactionResponse 创建订单成功返回
|
// CreateTransactionResponse 创建订单成功返回
|
||||||
type CreateTransactionResponse struct {
|
type CreateTransactionResponse struct {
|
||||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
|
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
||||||
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
Currency string `json:"currency" example:"CNY"` // 订单货币类型 CNY USD......
|
||||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
||||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/T2026041612345678"` // 收银台地址
|
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||||
|
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 收银台地址
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderNotifyResponse 订单异步回调结构体
|
// OrderNotifyResponse 订单异步回调结构体
|
||||||
type OrderNotifyResponse struct {
|
type OrderNotifyResponse struct {
|
||||||
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
|
Pid string `json:"pid" example:"1000"` // 签名使用的商户 PID,商户据此查本地 secret 验签
|
||||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||||
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
OrderId string `json:"order_id" example:"ORD20260416001"` // 客户交易id
|
||||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数
|
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数
|
||||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||||
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id
|
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123..."` // 区块id
|
||||||
Signature string `json:"signature" example:"a1b2c3d4e5f6..."` // 签名 MD5(sorted_params + secret_key)
|
Signature string `json:"signature" example:"a1b2c3d4e5f6..."` // 签名 MD5(sorted_params + secret_key)
|
||||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||||
Status int `json:"status" enums:"1,2,3" example:"2"`
|
Status int `json:"status" enums:"1,2,3" example:"2"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderNotifyResponseEpay epay订单异步回调结构体
|
// OrderNotifyResponseEpay epay订单异步回调结构体
|
||||||
type OrderNotifyResponseEpay struct {
|
type OrderNotifyResponseEpay struct {
|
||||||
PID int `json:"pid" example:"1001"` // 商户ID
|
PID int `json:"pid" example:"1001"` // 商户ID
|
||||||
TradeNo string `json:"trade_no" example:"T2026041612345678"` // 平台订单号
|
TradeNo string `json:"trade_no" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 平台订单号
|
||||||
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
|
OutTradeNo string `json:"out_trade_no" example:"ORD20260416001"` // 商户订单号
|
||||||
Type string `json:"type" example:"usdt"` // 订单类型
|
Type string `json:"type" example:"usdt"` // 订单类型
|
||||||
Name string `json:"name" example:"VIP月卡"` // 商品名称
|
Name string `json:"name" example:"VIP月卡"` // 商品名称
|
||||||
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
|
Money string `json:"money" example:"100.0000"` // 订单金额,保留4位小数
|
||||||
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
|
Sign string `json:"sign" example:"a1b2c3d4..."` // 签名
|
||||||
SignType string `json:"sign_type" example:"MD5"` // 签名类型
|
SignType string `json:"sign_type" example:"MD5"` // 签名类型
|
||||||
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
|
TradeStatus string `json:"trade_status" example:"TRADE_SUCCESS"` // 订单状态
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
package response
|
package response
|
||||||
|
|
||||||
type CheckoutCounterResponse struct {
|
type CheckoutCounterResponse struct {
|
||||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,保留4位小数 法币金额
|
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
|
||||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,保留4位小数 加密货币金额
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额;status=4 占位订单返回 0
|
||||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
Token string `json:"token" example:"USDT"` // 所属币种;status=4 占位订单为空
|
||||||
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
|
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
|
||||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址;status=4 占位订单为空
|
||||||
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
|
Network string `json:"network" example:"tron"` // 网络 TRON ETH;status=4 占位订单为空
|
||||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay
|
||||||
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
|
PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回
|
||||||
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||||
IsSelected bool `json:"is_selected" example:"false"`
|
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"` // 非 EPay 时为商户原始回跳地址;EPay 时为内部中转地址 /pay/return/{trade_id}
|
||||||
|
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单为空,OkPay 订单为第三方 payLink
|
||||||
|
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
||||||
|
IsSelected bool `json:"is_selected" example:"false"` // 是否已选择当前支付方式;status=4 占位订单和刚补全的占位父单为 false
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckStatusResponse struct {
|
type CheckStatusResponse struct {
|
||||||
TradeId string `json:"trade_id" example:"T2026041612345678"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
||||||
Status int `json:"status" enums:"1,2,3" example:"1"`
|
Status int `json:"status" enums:"1,2,3,4" example:"1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ManualPaymentResponse struct {
|
||||||
|
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||||
|
Status int `json:"status" enums:"1,2,3" example:"2"` // 订单状态 1=等待支付 2=支付成功 3=已过期
|
||||||
|
BlockTransactionId string `json:"block_transaction_id" example:"0xabc123def456..."` // 已验证并入账的交易哈希
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,41 @@
|
|||||||
package response
|
package response
|
||||||
|
|
||||||
type NetworkTokenSupport struct {
|
type NetworkTokenSupport struct {
|
||||||
Network string `json:"network" example:"tron"`
|
Network string `json:"network" example:"tron"`
|
||||||
Tokens []string `json:"tokens" example:"USDT,USDC"`
|
DisplayName string `json:"display_name" example:"TRON"`
|
||||||
|
Tokens []string `json:"tokens" example:"USDT,USDC"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SupportedAssetsResponse struct {
|
type EpayPublicConfig struct {
|
||||||
Supports []NetworkTokenSupport `json:"supports"`
|
DefaultToken string `json:"default_token" example:""` // EPay default token; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
|
||||||
|
DefaultCurrency string `json:"default_currency" example:"cny"` // EPay default fiat currency; falls back to cny when unset.
|
||||||
|
DefaultNetwork string `json:"default_network" example:""` // EPay default network; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
|
||||||
|
}
|
||||||
|
|
||||||
|
type SitePublicConfig struct {
|
||||||
|
CashierName string `json:"cashier_name" example:"Acme Cashier"`
|
||||||
|
LogoURL string `json:"logo_url" example:"https://cdn.example.com/logo.png"`
|
||||||
|
WebsiteTitle string `json:"website_title" example:"Acme Payments"`
|
||||||
|
SupportLink string `json:"support_link" example:"https://example.com/support"`
|
||||||
|
BackgroundColor string `json:"background_color" example:"#0f172a"`
|
||||||
|
BackgroundImageURL string `json:"background_image_url" example:"https://cdn.example.com/background.png"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OkPayPublicConfig struct {
|
||||||
|
Enabled bool `json:"enabled" example:"true"`
|
||||||
|
AllowTokens []string `json:"allow_tokens" example:"USDT,TRX"`
|
||||||
|
ShopID string `json:"shop_id,omitempty" example:"okpay-shop-test"`
|
||||||
|
ShopToken string `json:"shop_token,omitempty" example:"secret-token"`
|
||||||
|
APIURL string `json:"api_url,omitempty" example:"https://api.okaypay.me/shop/"`
|
||||||
|
CallbackURL string `json:"callback_url,omitempty" example:"https://pay.example.com/payments/okpay/v1/notify"`
|
||||||
|
ReturnURL string `json:"return_url,omitempty" example:"https://pay.example.com/success"`
|
||||||
|
TimeoutSeconds int `json:"timeout_seconds,omitempty" example:"10"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PublicConfigResponse struct {
|
||||||
|
SupportedAssets []NetworkTokenSupport `json:"supported_assets"`
|
||||||
|
Site SitePublicConfig `json:"site"`
|
||||||
|
Epay EpayPublicConfig `json:"epay"`
|
||||||
|
OkPay OkPayPublicConfig `json:"okpay"`
|
||||||
|
Version string `json:"version" example:"v1.0.1"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,526 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AptosGetLedgerVersion() (int64, error) {
|
||||||
|
body, err := aptosGet("/v1")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
latest := gjson.GetBytes(body, "ledger_version").Int()
|
||||||
|
log.Sugar.Debugf("[APTOS] latest ledger version=%d", latest)
|
||||||
|
return latest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AptosGetTransactionByHash(txID string) ([]byte, error) {
|
||||||
|
txID = normalizeAptosTxID(txID)
|
||||||
|
log.Sugar.Infof("[APTOS] fetch transaction by hash tx=%s", txID)
|
||||||
|
return aptosGet(fmt.Sprintf("/v1/transactions/by_hash/%s", txID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func AptosGetTransactions(start int64, limit int64) ([]byte, error) {
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 1
|
||||||
|
}
|
||||||
|
log.Sugar.Debugf("[APTOS] fetch transactions start=%d limit=%d", start, limit)
|
||||||
|
return aptosGet(fmt.Sprintf("/v1/transactions?start=%d&limit=%d", start, limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseAptosTransfers(body []byte, address string, tokens []mdb.ChainToken) ([]MoveObservedTransfer, error) {
|
||||||
|
receive, err := addressutil.NormalizeMoveAddress(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ParseAptosTransfersForWallets(body, map[string]struct{}{receive: {}}, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseAptosTransfersForWallets(body []byte, wallets map[string]struct{}, tokens []mdb.ChainToken) ([]MoveObservedTransfer, error) {
|
||||||
|
root := gjson.ParseBytes(body)
|
||||||
|
if !root.IsArray() {
|
||||||
|
return nil, fmt.Errorf("unexpected Aptos transactions response")
|
||||||
|
}
|
||||||
|
var out []MoveObservedTransfer
|
||||||
|
for _, tx := range root.Array() {
|
||||||
|
if tx.Get("type").String() != "" && tx.Get("type").String() != "user_transaction" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !tx.Get("success").Bool() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
txID := strings.TrimSpace(tx.Get("hash").String())
|
||||||
|
if txID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
blockTimeMs := parseAptosTimestampMs(tx.Get("timestamp"))
|
||||||
|
version := tx.Get("version").Int()
|
||||||
|
txctx := buildAptosTransferContext(tx)
|
||||||
|
events := buildAptosFungibleEvents(tx, txctx, tokens)
|
||||||
|
deposits := make([]aptosFungibleEvent, 0)
|
||||||
|
withdrawals := make(map[string]int)
|
||||||
|
for _, event := range events {
|
||||||
|
if event.token == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch event.action {
|
||||||
|
case "withdraw":
|
||||||
|
withdrawals[aptosTransferMatchKey(event.token.Symbol, event.rawAmount)]++
|
||||||
|
case "deposit":
|
||||||
|
deposits = append(deposits, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usedDeposits := make([]bool, len(deposits))
|
||||||
|
for i, event := range deposits {
|
||||||
|
matchKey := aptosTransferMatchKey(event.token.Symbol, event.rawAmount)
|
||||||
|
if withdrawals[matchKey] <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if appendAptosObservedTransfer(&out, txID, version, blockTimeMs, event, wallets) {
|
||||||
|
withdrawals[matchKey]--
|
||||||
|
usedDeposits[i] = true
|
||||||
|
} else {
|
||||||
|
logAptosUnwatchedDeposit(txID, version, event, "direct")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < len(deposits); i++ {
|
||||||
|
if usedDeposits[i] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for j := i + 1; j < len(deposits); j++ {
|
||||||
|
if usedDeposits[j] || !aptosDepositPairCanMatch(deposits[i], deposits[j]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sum := new(big.Int).Add(deposits[i].rawAmount, deposits[j].rawAmount)
|
||||||
|
matchKey := aptosTransferMatchKey(deposits[i].token.Symbol, sum)
|
||||||
|
if withdrawals[matchKey] <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
appendedFirst := appendAptosObservedTransfer(&out, txID, version, blockTimeMs, deposits[i], wallets)
|
||||||
|
appendedSecond := appendAptosObservedTransfer(&out, txID, version, blockTimeMs, deposits[j], wallets)
|
||||||
|
if !appendedFirst && !appendedSecond {
|
||||||
|
logAptosUnwatchedDeposit(txID, version, deposits[i], "split")
|
||||||
|
logAptosUnwatchedDeposit(txID, version, deposits[j], "split")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
withdrawals[matchKey]--
|
||||||
|
usedDeposits[i] = true
|
||||||
|
usedDeposits[j] = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendAptosObservedTransfer(out *[]MoveObservedTransfer, txID string, version int64, blockTimeMs int64, event aptosFungibleEvent, wallets map[string]struct{}) bool {
|
||||||
|
recipient := event.owner
|
||||||
|
if !moveWalletWatched(recipient, wallets) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
*out = append(*out, MoveObservedTransfer{
|
||||||
|
Network: mdb.NetworkAptos,
|
||||||
|
ReceiveAddress: recipient,
|
||||||
|
Token: NormalizeAptosPaymentSymbol(event.token.Symbol),
|
||||||
|
RawAmount: event.rawAmount,
|
||||||
|
Decimals: event.token.Decimals,
|
||||||
|
MinAmount: event.token.MinAmount,
|
||||||
|
Amount: rawToFloat(event.rawAmount, event.token.Decimals),
|
||||||
|
TxID: normalizeAptosTxID(txID),
|
||||||
|
BlockTimeMs: blockTimeMs,
|
||||||
|
Version: version,
|
||||||
|
TransferKey: moveAptosTransferKey(version, event.eventIndex, recipient, event.token.Symbol, event.rawAmount),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func logAptosUnwatchedDeposit(txID string, version int64, event aptosFungibleEvent, matchMode string) {
|
||||||
|
log.Sugar.Debugf(
|
||||||
|
"[APTOS] matched deposit skipped owner_not_watched mode=%s version=%d tx=%s token=%s amount=%.8f raw=%s owner=%s",
|
||||||
|
matchMode,
|
||||||
|
version,
|
||||||
|
normalizeAptosTxID(txID),
|
||||||
|
NormalizeAptosPaymentSymbol(event.token.Symbol),
|
||||||
|
rawToFloat(event.rawAmount, event.token.Decimals),
|
||||||
|
event.rawAmount.String(),
|
||||||
|
event.owner,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aptosDepositPairCanMatch(first, second aptosFungibleEvent) bool {
|
||||||
|
if first.token == nil || second.token == nil || first.rawAmount == nil || second.rawAmount == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if NormalizeAptosPaymentSymbol(first.token.Symbol) != NormalizeAptosPaymentSymbol(second.token.Symbol) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return first.token.ContractAddress == second.token.ContractAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
type aptosFungibleEvent struct {
|
||||||
|
eventIndex int
|
||||||
|
action string
|
||||||
|
owner string
|
||||||
|
rawAmount *big.Int
|
||||||
|
token *mdb.ChainToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAptosFungibleEvents(tx gjson.Result, ctx aptosTransferContext, tokens []mdb.ChainToken) []aptosFungibleEvent {
|
||||||
|
out := make([]aptosFungibleEvent, 0)
|
||||||
|
for eventIndex, event := range tx.Get("events").Array() {
|
||||||
|
action := aptosFungibleEventAction(event)
|
||||||
|
if action == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rawAmount, ok := parseSignedBigInt(aptosEventRawAmount(event, ctx))
|
||||||
|
if !ok || rawAmount.Sign() <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
token := resolveAptosPaymentToken(aptosEventAssetID(event, ctx), tokens)
|
||||||
|
if token == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, aptosFungibleEvent{
|
||||||
|
eventIndex: eventIndex,
|
||||||
|
action: action,
|
||||||
|
owner: aptosEventRecipient(event, ctx),
|
||||||
|
rawAmount: rawAmount,
|
||||||
|
token: token,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func aptosFungibleEventAction(event gjson.Result) string {
|
||||||
|
typ := strings.ToLower(strings.TrimSpace(event.Get("type").String()))
|
||||||
|
switch typ {
|
||||||
|
case "0x1::fungible_asset::deposit":
|
||||||
|
return "deposit"
|
||||||
|
case "0x1::fungible_asset::withdraw":
|
||||||
|
return "withdraw"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func aptosTransferMatchKey(symbol string, rawAmount *big.Int) string {
|
||||||
|
raw := ""
|
||||||
|
if rawAmount != nil {
|
||||||
|
raw = rawAmount.String()
|
||||||
|
}
|
||||||
|
return NormalizeAptosPaymentSymbol(symbol) + ":" + raw
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAptosPaymentToken(assetID string, tokens []mdb.ChainToken) *mdb.ChainToken {
|
||||||
|
return resolveMoveToken(mdb.NetworkAptos, assetID, tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeAptosPaymentSymbol(symbol string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(symbol))
|
||||||
|
}
|
||||||
|
|
||||||
|
type aptosTransferContext struct {
|
||||||
|
storeOwners map[string]string
|
||||||
|
storeMetadata map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAptosTransferContext(tx gjson.Result) aptosTransferContext {
|
||||||
|
ctx := aptosTransferContext{
|
||||||
|
storeOwners: make(map[string]string),
|
||||||
|
storeMetadata: make(map[string]string),
|
||||||
|
}
|
||||||
|
for _, change := range tx.Get("changes").Array() {
|
||||||
|
if change.Get("type").String() != "write_resource" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
store, err := addressutil.NormalizeMoveAddress(change.Get("address").String())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
changeType := strings.TrimSpace(change.Get("data.type").String())
|
||||||
|
switch changeType {
|
||||||
|
case "0x1::fungible_asset::FungibleStore":
|
||||||
|
if metadata := firstNonEmpty(
|
||||||
|
change.Get("data.data.metadata.inner").String(),
|
||||||
|
change.Get("data.data.metadata").String(),
|
||||||
|
); metadata != "" {
|
||||||
|
ctx.storeMetadata[store] = normalizeAptosObservedAssetID(metadata)
|
||||||
|
}
|
||||||
|
case "0x1::object::ObjectCore":
|
||||||
|
if owner := firstMoveAddress(change.Get("data.data.owner").String()); owner != "" {
|
||||||
|
ctx.storeOwners[store] = owner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func aptosEventRecipient(event gjson.Result, ctx aptosTransferContext) string {
|
||||||
|
if store := firstMoveAddress(event.Get("data.store").String()); store != "" {
|
||||||
|
if owner := ctx.storeOwners[store]; owner != "" {
|
||||||
|
return owner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func aptosEventRawAmount(event gjson.Result, ctx aptosTransferContext) string {
|
||||||
|
return event.Get("data.amount").String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func aptosEventAssetID(event gjson.Result, ctx aptosTransferContext) string {
|
||||||
|
if store := firstMoveAddress(event.Get("data.store").String()); store != "" {
|
||||||
|
if metadata := ctx.storeMetadata[store]; metadata != "" {
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAptosObservedAssetID(assetID string) string {
|
||||||
|
return addressutil.NormalizeMoveAssetID(assetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAptosTimestampMs(value gjson.Result) int64 {
|
||||||
|
raw := strings.TrimSpace(value.String())
|
||||||
|
if raw == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return n / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeAptosTxID(txID string) string {
|
||||||
|
txID = strings.ToLower(strings.TrimSpace(txID))
|
||||||
|
if strings.HasPrefix(txID, "0x") {
|
||||||
|
return txID
|
||||||
|
}
|
||||||
|
return "0x" + txID
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateManualAptosPayment(order *mdb.Orders, txID string) (string, error) {
|
||||||
|
txID = normalizeAptosTxID(txID)
|
||||||
|
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifyErrors []string
|
||||||
|
for _, node := range nodes {
|
||||||
|
if strings.TrimSpace(node.Url) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err = validateManualAptosPaymentWithNode(order, txID, node); err != nil {
|
||||||
|
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", data.RpcNodeLogLabel(node), err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return txID, nil
|
||||||
|
}
|
||||||
|
if len(verifyErrors) > 0 {
|
||||||
|
return "", fmt.Errorf("manual Aptos verification failed: %s", strings.Join(verifyErrors, "; "))
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualAptosPaymentWithNode(order *mdb.Orders, txID string, node mdb.RpcNode) (string, error) {
|
||||||
|
txID = normalizeAptosTxID(txID)
|
||||||
|
body, err := aptosGetWithNode(node, fmt.Sprintf("/v1/transactions/by_hash/%s", txID))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fetch aptos transaction: %w", err)
|
||||||
|
}
|
||||||
|
if err = validateManualAptosTransactionWithConfirm(order, txID, body, func(version int64) error {
|
||||||
|
return ensureAptosTransferConfirmedWithNode(version, node)
|
||||||
|
}); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return txID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualAptosTransaction(order *mdb.Orders, txID string, body []byte) error {
|
||||||
|
return validateManualAptosTransactionWithConfirm(order, txID, body, EnsureAptosTransferConfirmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualAptosTransactionWithConfirm(order *mdb.Orders, txID string, body []byte, confirm func(int64) error) error {
|
||||||
|
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkAptos)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
transfers, err := ParseAptosTransfers([]byte("["+string(body)+"]"), order.ReceiveAddress, tokens)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
amountMismatch := false
|
||||||
|
for _, transfer := range transfers {
|
||||||
|
if !strings.EqualFold(transfer.TxID, txID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err = confirm(transfer.Version); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = EnsureMoveTransferMatchesOrder(order, transfer); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "amount mismatch") {
|
||||||
|
amountMismatch = true
|
||||||
|
} else {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if amountMismatch {
|
||||||
|
return fmt.Errorf("transaction amount mismatch")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("matching aptos transfer to order address not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func aptosGet(path string) (body []byte, err error) {
|
||||||
|
network := mdb.NetworkAptos
|
||||||
|
defer recordMoveRPCResult(network, &err)
|
||||||
|
|
||||||
|
tried := make([]uint64, 0, 3)
|
||||||
|
var lastErr error
|
||||||
|
for attempts := 0; attempts < 3; attempts++ {
|
||||||
|
node, resolveErr := resolveAptosRpcNode(tried...)
|
||||||
|
if resolveErr != nil {
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
return nil, resolveErr
|
||||||
|
}
|
||||||
|
if len(tried) > 0 {
|
||||||
|
log.Sugar.Warnf("[APTOS] trying alternate RPC node path=%s node=%s", path, data.RpcNodeLogLabel(*node))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err = aptosGetWithNode(*node, path)
|
||||||
|
if err == nil {
|
||||||
|
data.RecordRpcNodeSuccess(node.ID)
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
failures, cooling := data.RecordRpcNodeFailure(node.ID)
|
||||||
|
nodeLabel := data.RpcNodeLogLabel(*node)
|
||||||
|
if cooling {
|
||||||
|
log.Sugar.Warnf("[APTOS] RPC node reached fail threshold path=%s node=%s failures=%d/%d", path, nodeLabel, failures, data.RpcFailoverThreshold)
|
||||||
|
} else {
|
||||||
|
log.Sugar.Warnf("[APTOS] RPC node failed path=%s node=%s failures=%d/%d", path, nodeLabel, failures, data.RpcFailoverThreshold)
|
||||||
|
}
|
||||||
|
tried = append(tried, node.ID)
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAptosRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
|
||||||
|
node, err := data.SelectGeneralRpcNode(mdb.NetworkAptos, mdb.RpcNodeTypeHttp, excludeIDs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if node == nil || node.ID == 0 {
|
||||||
|
return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkAptos, mdb.RpcNodeTypeHttp)
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimSpace(node.Url)
|
||||||
|
if rpcURL == "" {
|
||||||
|
return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||||
|
}
|
||||||
|
node.Url = rpcURL
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aptosGetWithNode(node mdb.RpcNode, path string) (body []byte, err error) {
|
||||||
|
endpoint, err := aptosJoinURL(node.Url, path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, err := (&http.Client{Timeout: manualVerifyRequestTimeout}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ = io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode >= http.StatusBadRequest {
|
||||||
|
return nil, fmt.Errorf("aptos rpc HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aptosJoinURL(base, path string) (string, error) {
|
||||||
|
base = strings.TrimSpace(base)
|
||||||
|
if base == "" {
|
||||||
|
return "", fmt.Errorf("empty aptos rpc url")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(path) == "" {
|
||||||
|
return strings.TrimRight(base, "/"), nil
|
||||||
|
}
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
trimmedBase := strings.TrimRight(base, "/")
|
||||||
|
lowerBase := strings.ToLower(trimmedBase)
|
||||||
|
if strings.HasSuffix(lowerBase, "/v1") && strings.HasPrefix(path, "/v1") {
|
||||||
|
path = strings.TrimPrefix(path, "/v1")
|
||||||
|
if path == "" {
|
||||||
|
path = "/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmedBase + path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureAptosTransferConfirmed(version int64) error {
|
||||||
|
if version <= 0 {
|
||||||
|
return fmt.Errorf("aptos transaction version missing")
|
||||||
|
}
|
||||||
|
latest, err := AptosGetLedgerVersion()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch aptos latest ledger version: %w", err)
|
||||||
|
}
|
||||||
|
return ensureAptosConfirmations(version, latest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureAptosTransferConfirmedWithNode(version int64, node mdb.RpcNode) error {
|
||||||
|
if version <= 0 {
|
||||||
|
return fmt.Errorf("aptos transaction version missing")
|
||||||
|
}
|
||||||
|
body, err := aptosGetWithNode(node, "/v1")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch aptos latest ledger version: %w", err)
|
||||||
|
}
|
||||||
|
latest := gjson.GetBytes(body, "ledger_version").Int()
|
||||||
|
return ensureAptosConfirmations(version, latest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureAptosConfirmations(version, latest int64) error {
|
||||||
|
chain, err := data.GetChainByNetwork(mdb.NetworkAptos)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
minConfirmations := int64(1)
|
||||||
|
if chain != nil && chain.MinConfirmations > 0 {
|
||||||
|
minConfirmations = int64(chain.MinConfirmations)
|
||||||
|
}
|
||||||
|
if latest-version+1 < minConfirmations {
|
||||||
|
return fmt.Errorf("aptos transaction confirmations insufficient")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/sign"
|
||||||
|
)
|
||||||
|
|
||||||
|
type EPayReturnRedirect struct {
|
||||||
|
TargetURL string
|
||||||
|
IsMerchantRedirect bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEPayOrder(order *mdb.Orders) bool {
|
||||||
|
return order != nil && strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCheckoutCounterPath(tradeID string) string {
|
||||||
|
return "/pay/checkout-counter/" + tradeID
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAbsoluteAppURL(path string) string {
|
||||||
|
path = strings.TrimSpace(path)
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/") {
|
||||||
|
path = "/" + path
|
||||||
|
}
|
||||||
|
base := strings.TrimRight(strings.TrimSpace(config.GetAppUri()), "/")
|
||||||
|
if base == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
return base + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEPayReturnPath(tradeID string) string {
|
||||||
|
return "/pay/return/" + tradeID
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPublicRedirectURL(order *mdb.Orders) string {
|
||||||
|
if order == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(order.RedirectUrl)
|
||||||
|
if raw == "" || !isEPayOrder(order) {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return buildAbsoluteAppURL(buildEPayReturnPath(order.TradeId))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
|
||||||
|
if order == nil || order.ApiKeyID == 0 {
|
||||||
|
return nil, constant.OrderApiKeyUnavailableErr
|
||||||
|
}
|
||||||
|
row, err := data.GetApiKeyByID(order.ApiKeyID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if row == nil || row.ID == 0 || row.Status != mdb.ApiKeyStatusEnable {
|
||||||
|
return nil, constant.OrderApiKeyUnavailableErr
|
||||||
|
}
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildEPayResultParams(order *mdb.Orders, apiKeyRow *mdb.ApiKey) (map[string]string, error) {
|
||||||
|
if order == nil || apiKeyRow == nil {
|
||||||
|
return nil, constant.EPayReturnSignatureErr
|
||||||
|
}
|
||||||
|
|
||||||
|
pidInt, err := strconv.Atoi(strings.TrimSpace(apiKeyRow.Pid))
|
||||||
|
if err != nil {
|
||||||
|
return nil, constant.EPayReturnSignatureErr
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyData := response.OrderNotifyResponseEpay{
|
||||||
|
PID: pidInt,
|
||||||
|
TradeNo: order.TradeId,
|
||||||
|
OutTradeNo: order.OrderId,
|
||||||
|
Type: "alipay",
|
||||||
|
Name: order.Name,
|
||||||
|
Money: fmt.Sprintf("%.4f", order.Amount),
|
||||||
|
TradeStatus: "TRADE_SUCCESS",
|
||||||
|
}
|
||||||
|
|
||||||
|
signstr, err := sign.Get(notifyData, apiKeyRow.SecretKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, constant.EPayReturnSignatureErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]string{
|
||||||
|
"pid": strconv.Itoa(pidInt),
|
||||||
|
"trade_no": notifyData.TradeNo,
|
||||||
|
"out_trade_no": notifyData.OutTradeNo,
|
||||||
|
"type": notifyData.Type,
|
||||||
|
"name": notifyData.Name,
|
||||||
|
"money": notifyData.Money,
|
||||||
|
"trade_status": notifyData.TradeStatus,
|
||||||
|
"sign": signstr,
|
||||||
|
"sign_type": "MD5",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendQueryParams(rawURL string, params map[string]string) (string, error) {
|
||||||
|
rawURL = strings.TrimSpace(rawURL)
|
||||||
|
if rawURL == "" {
|
||||||
|
return "", constant.OrderRedirectURLErr
|
||||||
|
}
|
||||||
|
|
||||||
|
targetURL, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", constant.OrderRedirectURLErr
|
||||||
|
}
|
||||||
|
|
||||||
|
query := targetURL.Query()
|
||||||
|
for key, value := range params {
|
||||||
|
query.Set(key, value)
|
||||||
|
}
|
||||||
|
targetURL.RawQuery = query.Encode()
|
||||||
|
return targetURL.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveEPayReturnRedirect(tradeID string) (*EPayReturnRedirect, error) {
|
||||||
|
order, err := GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isEPayOrder(order) || order.Status != mdb.StatusPaySuccess {
|
||||||
|
return &EPayReturnRedirect{
|
||||||
|
TargetURL: buildCheckoutCounterPath(order.TradeId),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rawMerchantURL := strings.TrimSpace(order.RedirectUrl)
|
||||||
|
if rawMerchantURL == "" {
|
||||||
|
return nil, constant.OrderRedirectURLErr
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKeyRow, err := ResolveOrderApiKey(order)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
params, err := BuildEPayResultParams(order, apiKeyRow)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetURL, err := appendQueryParams(rawMerchantURL, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &EPayReturnRedirect{
|
||||||
|
TargetURL: targetURL,
|
||||||
|
IsMerchantRedirect: true,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/sign"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildPublicRedirectURLRewritesOnlyEpayOrders(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
epayOrder := &mdb.Orders{
|
||||||
|
TradeId: "trade_epay_redirect",
|
||||||
|
PaymentType: mdb.PaymentTypeEpay,
|
||||||
|
RedirectUrl: "https://merchant.example/return",
|
||||||
|
}
|
||||||
|
wantRedirect := config.GetAppUri() + "/pay/return/trade_epay_redirect"
|
||||||
|
if got := buildPublicRedirectURL(epayOrder); got != wantRedirect {
|
||||||
|
t.Fatalf("epay redirect_url = %q, want internal return route", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
gmpayOrder := &mdb.Orders{
|
||||||
|
TradeId: "trade_gmpay_redirect",
|
||||||
|
PaymentType: mdb.PaymentTypeGmpay,
|
||||||
|
RedirectUrl: "https://merchant.example/return",
|
||||||
|
}
|
||||||
|
if got := buildPublicRedirectURL(gmpayOrder); got != "https://merchant.example/return" {
|
||||||
|
t.Fatalf("gmpay redirect_url = %q, want merchant raw redirect_url", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := buildPublicRedirectURL(&mdb.Orders{TradeId: "trade_empty", PaymentType: mdb.PaymentTypeEpay}); got != "" {
|
||||||
|
t.Fatalf("empty redirect_url = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEPayResultParamsMatchesLegacyFields(t *testing.T) {
|
||||||
|
params, err := BuildEPayResultParams(&mdb.Orders{
|
||||||
|
TradeId: "trade_epay_params",
|
||||||
|
OrderId: "order_epay_params",
|
||||||
|
Name: "VIP",
|
||||||
|
Amount: 1,
|
||||||
|
}, &mdb.ApiKey{
|
||||||
|
Pid: "1001",
|
||||||
|
SecretKey: "epay-secret",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildEPayResultParams(): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expected := map[string]string{
|
||||||
|
"pid": "1001",
|
||||||
|
"trade_no": "trade_epay_params",
|
||||||
|
"out_trade_no": "order_epay_params",
|
||||||
|
"type": "alipay",
|
||||||
|
"name": "VIP",
|
||||||
|
"money": "1.0000",
|
||||||
|
"trade_status": "TRADE_SUCCESS",
|
||||||
|
"sign_type": "MD5",
|
||||||
|
}
|
||||||
|
for key, want := range expected {
|
||||||
|
if got := params[key]; got != want {
|
||||||
|
t.Fatalf("%s = %q, want %q", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
signParams := map[string]interface{}{
|
||||||
|
"pid": params["pid"],
|
||||||
|
"trade_no": params["trade_no"],
|
||||||
|
"out_trade_no": params["out_trade_no"],
|
||||||
|
"type": params["type"],
|
||||||
|
"name": params["name"],
|
||||||
|
"money": params["money"],
|
||||||
|
"trade_status": params["trade_status"],
|
||||||
|
}
|
||||||
|
wantSign, err := sign.Get(signParams, "epay-secret")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign.Get(): %v", err)
|
||||||
|
}
|
||||||
|
if got := params["sign"]; got != wantSign {
|
||||||
|
t.Fatalf("sign = %q, want %q", got, wantSign)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildEPayResultParamsRejectsNonNumericPid(t *testing.T) {
|
||||||
|
_, err := BuildEPayResultParams(&mdb.Orders{
|
||||||
|
TradeId: "trade_bad_pid",
|
||||||
|
OrderId: "order_bad_pid",
|
||||||
|
Name: "VIP",
|
||||||
|
Amount: 1,
|
||||||
|
}, &mdb.ApiKey{
|
||||||
|
Pid: "not-a-number",
|
||||||
|
SecretKey: "epay-secret",
|
||||||
|
})
|
||||||
|
if err != constant.EPayReturnSignatureErr {
|
||||||
|
t.Fatalf("error = %v, want %v", err, constant.EPayReturnSignatureErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveOrderApiKeyRejectsUnavailableRows(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if _, err := ResolveOrderApiKey(&mdb.Orders{TradeId: "missing_api_key_id"}); err != constant.OrderApiKeyUnavailableErr {
|
||||||
|
t.Fatalf("missing api_key_id error = %v, want %v", err, constant.OrderApiKeyUnavailableErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
row := &mdb.ApiKey{
|
||||||
|
Name: "disabled",
|
||||||
|
Pid: "1201",
|
||||||
|
SecretKey: "disabled-secret",
|
||||||
|
Status: mdb.ApiKeyStatusDisable,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(row).Error; err != nil {
|
||||||
|
t.Fatalf("create disabled api key: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ResolveOrderApiKey(&mdb.Orders{TradeId: "disabled_api_key", ApiKeyID: row.ID}); err != constant.OrderApiKeyUnavailableErr {
|
||||||
|
t.Fatalf("disabled api key error = %v, want %v", err, constant.OrderApiKeyUnavailableErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendQueryParamsPreservesMerchantQuery(t *testing.T) {
|
||||||
|
target, err := appendQueryParams("https://merchant.example/return?from=merchant", map[string]string{
|
||||||
|
"pid": "1001",
|
||||||
|
"trade_no": "trade_epay_redirect",
|
||||||
|
"trade_status": "TRADE_SUCCESS",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("appendQueryParams(): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse(target)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse(): %v", err)
|
||||||
|
}
|
||||||
|
query := parsed.Query()
|
||||||
|
if got := query.Get("from"); got != "merchant" {
|
||||||
|
t.Fatalf("from = %q, want merchant", got)
|
||||||
|
}
|
||||||
|
if got := query.Get("pid"); got != "1001" {
|
||||||
|
t.Fatalf("pid = %q, want 1001", got)
|
||||||
|
}
|
||||||
|
if got := query.Get("trade_no"); got != "trade_epay_redirect" {
|
||||||
|
t.Fatalf("trade_no = %q, want trade_epay_redirect", got)
|
||||||
|
}
|
||||||
|
if got := query.Get("trade_status"); got != "TRADE_SUCCESS" {
|
||||||
|
t.Fatalf("trade_status = %q, want TRADE_SUCCESS", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,878 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
|
"github.com/dromara/carbon/v2"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
|
"github.com/xssnick/tonutils-go/tlb"
|
||||||
|
"github.com/xssnick/tonutils-go/ton"
|
||||||
|
"github.com/xssnick/tonutils-go/ton/jetton"
|
||||||
|
"github.com/xssnick/tonutils-go/tvm/cell"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func upsertTestChainToken(t *testing.T, token mdb.ChainToken) {
|
||||||
|
t.Helper()
|
||||||
|
if err := dao.Mdb.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "network"}, {Name: "symbol"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"contract_address",
|
||||||
|
"decimals",
|
||||||
|
"enabled",
|
||||||
|
"min_amount",
|
||||||
|
}),
|
||||||
|
}).Create(&token).Error; err != nil {
|
||||||
|
t.Fatalf("upsert token: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyEvmHashAcceptsOptional0x(t *testing.T) {
|
||||||
|
hash := strings.Repeat("a", 64)
|
||||||
|
if !isEvmHash(hash) {
|
||||||
|
t.Fatal("expected bare EVM hash to be valid")
|
||||||
|
}
|
||||||
|
if !isEvmHash("0x" + hash) {
|
||||||
|
t.Fatal("expected 0x-prefixed EVM hash to be valid")
|
||||||
|
}
|
||||||
|
if isEvmHash("0x" + strings.Repeat("a", 63)) {
|
||||||
|
t.Fatal("expected short EVM hash to be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyNormalizeEvmAddressAcceptsOptional0x(t *testing.T) {
|
||||||
|
addr := "1111111111111111111111111111111111111111"
|
||||||
|
want := common.HexToAddress("0x" + addr)
|
||||||
|
got, err := normalizeEvmAddress(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize bare address: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("bare address = %s, want %s", got.Hex(), want.Hex())
|
||||||
|
}
|
||||||
|
got, err = normalizeEvmAddress("0X" + strings.ToUpper(addr))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize prefixed address: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("prefixed address = %s, want %s", got.Hex(), want.Hex())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyNormalizeTronAddressHexAcceptsOptionalPrefix(t *testing.T) {
|
||||||
|
body := "a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
|
want := "41" + body
|
||||||
|
|
||||||
|
for _, input := range []string{body, "0x" + body, want, "0X" + strings.ToUpper(want)} {
|
||||||
|
got, err := normalizeTronAddressHex(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeTronAddressHex(%q): %v", input, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("normalizeTronAddressHex(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyNormalizeTronTxIDAcceptsOptional0x(t *testing.T) {
|
||||||
|
txID := strings.Repeat("a", 64)
|
||||||
|
got, err := normalizeTronTxID(txID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize bare txid: %v", err)
|
||||||
|
}
|
||||||
|
if got != txID {
|
||||||
|
t.Fatalf("bare txid = %q, want %q", got, txID)
|
||||||
|
}
|
||||||
|
got, err = normalizeTronTxID("0X" + strings.ToUpper(txID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize prefixed txid: %v", err)
|
||||||
|
}
|
||||||
|
if got != txID {
|
||||||
|
t.Fatalf("prefixed txid = %q, want %q", got, txID)
|
||||||
|
}
|
||||||
|
if _, err = normalizeTronTxID("0x" + strings.Repeat("a", 63)); err == nil {
|
||||||
|
t.Fatal("expected short txid to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManualAptosPayment(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, MinConfirmations: 1}).Error; err != nil {
|
||||||
|
t.Fatalf("create aptos chain: %v", err)
|
||||||
|
}
|
||||||
|
usdc := "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
|
||||||
|
if err := dao.Mdb.Create(&mdb.ChainToken{
|
||||||
|
Network: mdb.NetworkAptos,
|
||||||
|
Symbol: "USDC",
|
||||||
|
ContractAddress: usdc,
|
||||||
|
Decimals: 6,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create aptos usdc token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
receive, err := addressutil.NormalizeMoveAddress("0xa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize aptos receive: %v", err)
|
||||||
|
}
|
||||||
|
store, err := addressutil.NormalizeMoveAddress("0x11")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize aptos store: %v", err)
|
||||||
|
}
|
||||||
|
senderStore, err := addressutil.NormalizeMoveAddress("0x12")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize aptos sender store: %v", err)
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/v1/transactions/by_hash/"):
|
||||||
|
_, _ = w.Write([]byte(`{"type":"user_transaction","success":true,"hash":"0xabc","version":"101","timestamp":"1700000000123456","payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdc + `"},"` + receive + `","4200000"],"type":"entry_function_payload"},"events":[{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4200000","store":"` + senderStore + `"}},{"type":"0x1::fungible_asset::Deposit","data":{"amount":"4200000","store":"` + store + `"}}],"changes":[{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}]}`))
|
||||||
|
case r.URL.Path == "/v1":
|
||||||
|
_, _ = w.Write([]byte(`{"ledger_version":"101"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
seedTestAptosRPCNode(t, server.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
|
||||||
|
|
||||||
|
order := &mdb.Orders{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(1699999999000))},
|
||||||
|
Network: mdb.NetworkAptos,
|
||||||
|
Token: "USDC",
|
||||||
|
ActualAmount: 4.2,
|
||||||
|
ReceiveAddress: receive,
|
||||||
|
}
|
||||||
|
got, err := ValidateManualAptosPayment(order, "ABC")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateManualAptosPayment(): %v", err)
|
||||||
|
}
|
||||||
|
if got != "0xabc" {
|
||||||
|
t.Fatalf("canonical tx id = %q, want %q", got, "0xabc")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseManualTonTxRefAcceptsCanonicalLTAndHashOnly(t *testing.T) {
|
||||||
|
receiveRaw := "0:ba295e33b3c4c9b5265aa4ead1166a92931ce9abea120a8c5e91044a1257f89c"
|
||||||
|
hash := strings.Repeat("a", 64)
|
||||||
|
|
||||||
|
ref, err := parseManualTonTxRef("ton:" + receiveRaw + ":123:" + strings.ToUpper(hash))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse canonical TON tx ref: %v", err)
|
||||||
|
}
|
||||||
|
if ref.ReceiveRaw != receiveRaw || ref.LT != 123 || ref.HashHex != hash || !ref.HasLT {
|
||||||
|
t.Fatalf("canonical ref = %#v", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err = parseManualTonTxRef("456:0X" + strings.ToUpper(hash))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse lt:hash TON tx ref: %v", err)
|
||||||
|
}
|
||||||
|
if ref.ReceiveRaw != "" || ref.LT != 456 || ref.HashHex != hash || !ref.HasLT {
|
||||||
|
t.Fatalf("lt:hash ref = %#v", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err = parseManualTonTxRef("0X" + strings.ToUpper(hash))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse hash-only TON tx ref: %v", err)
|
||||||
|
}
|
||||||
|
if ref.ReceiveRaw != "" || ref.LT != 0 || ref.HashHex != hash || ref.HasLT {
|
||||||
|
t.Fatalf("hash-only ref = %#v", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeManualTonAPI struct {
|
||||||
|
ton.APIClientWrapped
|
||||||
|
master *ton.BlockIDExt
|
||||||
|
account *tlb.Account
|
||||||
|
txs []*tlb.Transaction
|
||||||
|
getAccountCalls int
|
||||||
|
listCalls []manualTonListCall
|
||||||
|
}
|
||||||
|
|
||||||
|
type manualTonListCall struct {
|
||||||
|
limit uint32
|
||||||
|
lt uint64
|
||||||
|
hashHex string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeManualTonAPI) CurrentMasterchainInfo(context.Context) (*ton.BlockIDExt, error) {
|
||||||
|
return f.master, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeManualTonAPI) WaitForBlock(uint32) ton.APIClientWrapped {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeManualTonAPI) GetAccount(context.Context, *ton.BlockIDExt, *address.Address) (*tlb.Account, error) {
|
||||||
|
f.getAccountCalls++
|
||||||
|
if f.account == nil {
|
||||||
|
return &tlb.Account{}, nil
|
||||||
|
}
|
||||||
|
return f.account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeManualTonAPI) ListTransactions(_ context.Context, _ *address.Address, limit uint32, lt uint64, txHash []byte) ([]*tlb.Transaction, error) {
|
||||||
|
f.listCalls = append(f.listCalls, manualTonListCall{
|
||||||
|
limit: limit,
|
||||||
|
lt: lt,
|
||||||
|
hashHex: hex.EncodeToString(txHash),
|
||||||
|
})
|
||||||
|
return f.txs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManualTonPaymentWithAPINativeTON(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Model(&mdb.ChainToken{}).
|
||||||
|
Where("network = ? AND symbol = ?", mdb.NetworkTon, "USDT").
|
||||||
|
Update("enabled", false).Error; err != nil {
|
||||||
|
t.Fatalf("disable TON USDT token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x66}, 32)).Bounce(false).Testnet(false)
|
||||||
|
tx := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
||||||
|
order := &mdb.Orders{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
Token: "TON",
|
||||||
|
ActualAmount: 1.23,
|
||||||
|
ReceiveAddress: receive.Bounce(false).String(),
|
||||||
|
}
|
||||||
|
api := &fakeManualTonAPI{
|
||||||
|
master: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: -0x8000000000000000, SeqNo: 9},
|
||||||
|
txs: []*tlb.Transaction{tx},
|
||||||
|
}
|
||||||
|
ref := manualTonTxRef{LT: tx.LT, HashHex: hex.EncodeToString(tx.Hash), HasLT: true}
|
||||||
|
|
||||||
|
got, err := validateManualTonPaymentWithAPI(context.Background(), api, order, receive, ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateManualTonPaymentWithAPI(): %v", err)
|
||||||
|
}
|
||||||
|
if api.getAccountCalls != 0 {
|
||||||
|
t.Fatalf("exact TON ref fetched account state %d times, want 0", api.getAccountCalls)
|
||||||
|
}
|
||||||
|
if len(api.listCalls) != 1 {
|
||||||
|
t.Fatalf("ListTransactions calls = %#v, want one exact lookup", api.listCalls)
|
||||||
|
}
|
||||||
|
if call := api.listCalls[0]; call.limit != 1 || call.lt != tx.LT || call.hashHex != hex.EncodeToString(tx.Hash) {
|
||||||
|
t.Fatalf("ListTransactions exact call = %#v, want limit=1 lt/hash from ref", call)
|
||||||
|
}
|
||||||
|
want := TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash))
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("canonical id = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManualTonPaymentCandidatesUSDTJetton(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x68}, 32)).Bounce(false).Testnet(false)
|
||||||
|
jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x69}, 32)).Bounce(false).Testnet(false)
|
||||||
|
body, err := tlb.ToCell(jetton.TransferNotification{
|
||||||
|
QueryID: 7,
|
||||||
|
Amount: tlb.MustFromNano(big.NewInt(1_000_000), 6),
|
||||||
|
Sender: sender,
|
||||||
|
ForwardPayload: cell.BeginCell().EndCell(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build jetton notification body: %v", err)
|
||||||
|
}
|
||||||
|
tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body)
|
||||||
|
order := &mdb.Orders{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
Token: "USDT",
|
||||||
|
ActualAmount: 1,
|
||||||
|
ReceiveAddress: receive.Bounce(false).String(),
|
||||||
|
}
|
||||||
|
state := &manualTonState{
|
||||||
|
jettonWallets: map[string]mdb.ChainToken{
|
||||||
|
addressutil.TonRawAddressObjectKey(jettonWallet): {
|
||||||
|
BaseModel: mdb.BaseModel{ID: 2},
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
Symbol: "USDT",
|
||||||
|
Decimals: 6,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := validateManualTonPaymentCandidates(order, receive, manualTonTxRef{
|
||||||
|
LT: tx.LT,
|
||||||
|
HashHex: hex.EncodeToString(tx.Hash),
|
||||||
|
HasLT: true,
|
||||||
|
}, []*tlb.Transaction{tx}, state)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateManualTonPaymentCandidates(): %v", err)
|
||||||
|
}
|
||||||
|
want := TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash))
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("canonical id = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManualTonPaymentWithAPIRejectsAmbiguousHashOnly(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Model(&mdb.ChainToken{}).
|
||||||
|
Where("network = ? AND symbol = ?", mdb.NetworkTon, "USDT").
|
||||||
|
Update("enabled", false).Error; err != nil {
|
||||||
|
t.Fatalf("disable TON USDT token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x67}, 32)).Bounce(false).Testnet(false)
|
||||||
|
tx1 := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
||||||
|
tx2 := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
||||||
|
tx2.LT = tx1.LT + 1
|
||||||
|
order := &mdb.Orders{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
Token: "TON",
|
||||||
|
ActualAmount: 1.23,
|
||||||
|
ReceiveAddress: receive.Bounce(false).String(),
|
||||||
|
}
|
||||||
|
api := &fakeManualTonAPI{
|
||||||
|
master: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: -0x8000000000000000, SeqNo: 9},
|
||||||
|
account: &tlb.Account{
|
||||||
|
LastTxLT: tx2.LT,
|
||||||
|
LastTxHash: tx2.Hash,
|
||||||
|
},
|
||||||
|
txs: []*tlb.Transaction{tx1, tx2},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := validateManualTonPaymentWithAPI(context.Background(), api, order, receive, manualTonTxRef{
|
||||||
|
HashHex: hex.EncodeToString(tx1.Hash),
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "matched multiple") {
|
||||||
|
t.Fatalf("validate ambiguous hash-only error = %v, want multiple-match error", err)
|
||||||
|
}
|
||||||
|
if api.getAccountCalls != 1 {
|
||||||
|
t.Fatalf("hash-only TON ref fetched account state %d times, want 1", api.getAccountCalls)
|
||||||
|
}
|
||||||
|
if len(api.listCalls) != 1 {
|
||||||
|
t.Fatalf("ListTransactions calls = %#v, want one recent lookup", api.listCalls)
|
||||||
|
}
|
||||||
|
if call := api.listCalls[0]; call.limit != 100 || call.lt != tx2.LT || call.hashHex != hex.EncodeToString(tx2.Hash) {
|
||||||
|
t.Fatalf("ListTransactions recent call = %#v, want account last tx cursor", call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Url: "http://127.0.0.1:1",
|
||||||
|
Enabled: true,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create rpc node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
clients, err := dialManualEvmClients(ctx, mdb.NetworkEthereum)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dialManualEvmClients(): %v", err)
|
||||||
|
}
|
||||||
|
defer closeManualEvmClients(clients)
|
||||||
|
if len(clients) != 1 {
|
||||||
|
t.Fatalf("client count = %d, want 1", len(clients))
|
||||||
|
}
|
||||||
|
if !strings.Contains(clients[0].label, mdb.RpcNodeTypeHttp) {
|
||||||
|
t.Fatalf("client label = %q, want HTTP node", clients[0].label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyEvmCandidatesPreferGeneralAcrossNodeTypes(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkEthereum, Type: mdb.RpcNodeTypeHttp, Url: "http://manual.example.com", Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkEthereum, Type: mdb.RpcNodeTypeWs, Url: "ws://general.example.com", Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := listManualEvmRpcCandidates(mdb.NetworkEthereum)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listManualEvmRpcCandidates(): %v", err)
|
||||||
|
}
|
||||||
|
urls := make([]string, 0, len(got))
|
||||||
|
for _, node := range got {
|
||||||
|
urls = append(urls, node.Url)
|
||||||
|
}
|
||||||
|
want := []string{"ws://general.example.com", "http://manual.example.com"}
|
||||||
|
if len(urls) != len(want) {
|
||||||
|
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if urls[i] != want[i] {
|
||||||
|
t.Fatalf("candidate urls = %#v, want %#v", urls, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualRpcNodeLabelHidesSensitiveURLParts(t *testing.T) {
|
||||||
|
label := manualRpcNodeLabel(mdb.RpcNode{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 9},
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Url: "https://rpc.example.com/v3/secret-key?token=secret",
|
||||||
|
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||||
|
})
|
||||||
|
if strings.Contains(label, "secret") || strings.Contains(label, "/v3/") {
|
||||||
|
t.Fatalf("manualRpcNodeLabel() leaked sensitive URL parts: %s", label)
|
||||||
|
}
|
||||||
|
if !strings.Contains(label, "endpoint=https://rpc.example.com") {
|
||||||
|
t.Fatalf("manualRpcNodeLabel() = %s, want sanitized endpoint", label)
|
||||||
|
}
|
||||||
|
if !strings.Contains(label, "purpose=manual_verify") {
|
||||||
|
t.Fatalf("manualRpcNodeLabel() = %s, want purpose", label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyDialEvmClientDoesNotForwardUserIPHeader(t *testing.T) {
|
||||||
|
var headerCalls int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||||
|
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||||
|
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atomic.AddInt32(&headerCalls, 1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"code": -32000,
|
||||||
|
"message": "not found",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
client, err := dialManualEvmClient(ctx, mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Url: server.URL,
|
||||||
|
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dialManualEvmClient(): %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
_, _ = client.TransactionReceipt(ctx, common.HexToHash("0x"+strings.Repeat("a", 64)))
|
||||||
|
if got := atomic.LoadInt32(&headerCalls); got == 0 {
|
||||||
|
t.Fatal("manual verify EVM request did not reach test RPC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyDialEvmClientDoesNotForwardUserIPForGeneralNode(t *testing.T) {
|
||||||
|
var calls int32
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||||
|
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||||
|
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atomic.AddInt32(&calls, 1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"code": -32000,
|
||||||
|
"message": "not found",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
client, err := dialManualEvmClient(ctx, mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Url: server.URL,
|
||||||
|
Purpose: mdb.RpcNodePurposeGeneral,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dialManualEvmClient(): %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
_, _ = client.TransactionReceipt(ctx, common.HexToHash("0x"+strings.Repeat("a", 64)))
|
||||||
|
if got := atomic.LoadInt32(&calls); got == 0 {
|
||||||
|
t.Fatal("general EVM request did not reach test RPC")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTronPostJSONDoesNotForwardUserIPWithoutHeaders(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||||
|
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||||
|
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
var out map[string]interface{}
|
||||||
|
if err := tronPostJSON(server.URL, "", "/wallet/getnowblock", map[string]interface{}{}, &out); err != nil {
|
||||||
|
t.Fatalf("tronPostJSON(): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyEvmRejectsTransactionBeforeOrder(t *testing.T) {
|
||||||
|
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
|
||||||
|
txTime := uint64(time.Now().Add(-time.Hour).Unix())
|
||||||
|
if err := ensureEvmTransactionNotBeforeOrder(txTime, order); err == nil {
|
||||||
|
t.Fatal("expected transaction before order to be rejected")
|
||||||
|
}
|
||||||
|
txTime = uint64(time.Now().Add(time.Minute).Unix())
|
||||||
|
if err := ensureEvmTransactionNotBeforeOrder(txTime, order); err != nil {
|
||||||
|
t.Fatalf("expected transaction after order to pass: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyCanonicalEvmHash(t *testing.T) {
|
||||||
|
hash := strings.Repeat("a", 64)
|
||||||
|
_, canonical, err := canonicalEvmHash("0X" + strings.ToUpper(hash))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("canonicalEvmHash(): %v", err)
|
||||||
|
}
|
||||||
|
if canonical != "0x"+hash {
|
||||||
|
t.Fatalf("canonical hash = %q, want %q", canonical, "0x"+hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyEquivalentBlockIDsCatchLegacyVariants(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
hash := strings.Repeat("b", 64)
|
||||||
|
mixedHash := "0x" + strings.Repeat("bB", 32)
|
||||||
|
if err := dao.Mdb.Create(&mdb.Orders{
|
||||||
|
TradeId: "paid-legacy-hash",
|
||||||
|
OrderId: "paid-legacy-hash",
|
||||||
|
Status: mdb.StatusPaySuccess,
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
BlockTransactionId: mixedHash,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create existing order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
order := &mdb.Orders{BaseModel: mdb.BaseModel{ID: 999}, Network: mdb.NetworkEthereum}
|
||||||
|
if err := ensureManualBlockTransactionUnused(order, "0x"+hash); err == nil {
|
||||||
|
t.Fatal("expected legacy hash variant to be treated as already processed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyEvmRequestFailureFallsBackToNextClient(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
contract := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||||
|
to := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||||
|
rawAmount := big.NewInt(1230000)
|
||||||
|
receipt := &types.Receipt{
|
||||||
|
Status: types.ReceiptStatusSuccessful,
|
||||||
|
BlockNumber: big.NewInt(10),
|
||||||
|
Logs: []*types.Log{{
|
||||||
|
Address: contract,
|
||||||
|
Topics: []common.Hash{
|
||||||
|
erc20TransferEventHash,
|
||||||
|
common.Hash{},
|
||||||
|
common.BytesToHash(to.Bytes()),
|
||||||
|
},
|
||||||
|
Data: common.LeftPadBytes(rawAmount.Bytes(), 32),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
order := &mdb.Orders{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Token: "USDT",
|
||||||
|
ActualAmount: 1.23,
|
||||||
|
ReceiveAddress: to.Hex(),
|
||||||
|
BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||||
|
}
|
||||||
|
token := &mdb.ChainToken{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: contract.Hex(), Decimals: 6}
|
||||||
|
|
||||||
|
err := validateManualEvmPaymentAcrossClients(context.Background(), []manualEvmClient{
|
||||||
|
{label: "ws bad", reader: &fakeEvmReader{receiptErr: errors.New("ws receipt failed")}},
|
||||||
|
{label: "http ok", reader: &fakeEvmReader{
|
||||||
|
receipt: receipt,
|
||||||
|
headers: map[string]*types.Header{
|
||||||
|
"10": {Number: big.NewInt(10), Time: uint64(time.Now().Unix())},
|
||||||
|
"latest": {Number: big.NewInt(12), Time: uint64(time.Now().Unix())},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}, order, common.HexToHash("0x"+strings.Repeat("c", 64)), token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateManualEvmPaymentAcrossClients(): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyTronTRC20UsesTransferEvent(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
contractHex := "411111111111111111111111111111111111111111"
|
||||||
|
recipientHex := "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
|
contractAddress, err := tronHexToAddress(contractHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("contract address: %v", err)
|
||||||
|
}
|
||||||
|
recipientAddress, err := tronHexToAddress(recipientHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recipient address: %v", err)
|
||||||
|
}
|
||||||
|
upsertTestChainToken(t, mdb.ChainToken{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Symbol: "USDT",
|
||||||
|
ContractAddress: contractAddress,
|
||||||
|
Decimals: 6,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
rawAmount := big.NewInt(1230000)
|
||||||
|
tx := manualTronTransactionFromCallData(t, contractHex, recipientHex, rawAmount)
|
||||||
|
info := &manualTronTxInfo{Log: []manualTronEventLog{{
|
||||||
|
Address: strings.TrimPrefix(contractHex, "41"),
|
||||||
|
Topics: []string{
|
||||||
|
"0x" + strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x"),
|
||||||
|
strings.Repeat("0", 64),
|
||||||
|
"0x" + strings.Repeat("0", 24) + strings.TrimPrefix(recipientHex, "41"),
|
||||||
|
},
|
||||||
|
Data: "0x" + fmt.Sprintf("%064x", rawAmount),
|
||||||
|
}}}
|
||||||
|
order := &mdb.Orders{ReceiveAddress: recipientAddress, Token: "USDT", ActualAmount: 1.23}
|
||||||
|
|
||||||
|
if err = validateManualTronTRC20Transfer(order, &tx, info); err != nil {
|
||||||
|
t.Fatalf("validateManualTronTRC20Transfer(): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Log[0].Data = "0x" + fmt.Sprintf("%064x", big.NewInt(1240000))
|
||||||
|
if err = validateManualTronTRC20Transfer(order, &tx, info); err == nil {
|
||||||
|
t.Fatal("expected event amount mismatch to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
txID := strings.Repeat("a", 64)
|
||||||
|
contractHex := "411111111111111111111111111111111111111111"
|
||||||
|
recipientHex := "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
|
contractAddress, err := tronHexToAddress(contractHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("contract address: %v", err)
|
||||||
|
}
|
||||||
|
recipientAddress, err := tronHexToAddress(recipientHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recipient address: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawAmount := big.NewInt(1230000)
|
||||||
|
blockTimeMs := time.Now().Add(time.Minute).UnixMilli()
|
||||||
|
requestCalls := 0
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||||
|
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||||
|
}
|
||||||
|
requestCalls++
|
||||||
|
var req map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Errorf("decode tron request: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/wallet/getnowblock" && req["value"] != txID {
|
||||||
|
t.Errorf("request tx id = %q, want %q", req["value"], txID)
|
||||||
|
http.Error(w, "bad tx id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/wallet/gettransactionbyid":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"txID": txID,
|
||||||
|
"raw_data": map[string]interface{}{
|
||||||
|
"contract": []map[string]interface{}{{
|
||||||
|
"type": "TriggerSmartContract",
|
||||||
|
"parameter": map[string]interface{}{
|
||||||
|
"value": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
"ret": []map[string]string{{"contractRet": "SUCCESS"}},
|
||||||
|
})
|
||||||
|
case "/wallet/gettransactioninfobyid":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"id": txID,
|
||||||
|
"blockNumber": 100,
|
||||||
|
"blockTimeStamp": blockTimeMs,
|
||||||
|
"receipt": map[string]string{"result": "SUCCESS"},
|
||||||
|
"log": []map[string]interface{}{{
|
||||||
|
"address": strings.TrimPrefix(contractHex, "41"),
|
||||||
|
"topics": []string{
|
||||||
|
strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x"),
|
||||||
|
strings.Repeat("0", 64),
|
||||||
|
"0X" + strings.ToUpper(strings.Repeat("0", 24)+strings.TrimPrefix(recipientHex, "41")),
|
||||||
|
},
|
||||||
|
"data": fmt.Sprintf("%064x", rawAmount),
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case "/wallet/getnowblock":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"block_header": map[string]interface{}{
|
||||||
|
"raw_data": map[string]interface{}{"number": 110},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err = dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Url: server.URL,
|
||||||
|
Enabled: true,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create tron rpc node: %v", err)
|
||||||
|
}
|
||||||
|
upsertTestChainToken(t, mdb.ChainToken{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Symbol: "USDT",
|
||||||
|
ContractAddress: contractAddress,
|
||||||
|
Decimals: 6,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: "manual-tron-http-flow",
|
||||||
|
OrderId: "manual-tron-http-flow",
|
||||||
|
ActualAmount: 1.23,
|
||||||
|
ReceiveAddress: recipientAddress,
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
|
}
|
||||||
|
if err = dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("create order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ValidateManualOrderPayment(order, "0X"+strings.ToUpper(txID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateManualOrderPayment(): %v", err)
|
||||||
|
}
|
||||||
|
if got != txID {
|
||||||
|
t.Fatalf("canonical tx id = %q, want %q", got, txID)
|
||||||
|
}
|
||||||
|
if requestCalls != 3 {
|
||||||
|
t.Fatalf("manual verify request calls = %d, want 3", requestCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifySolanaRejectsMissingBlockTime(t *testing.T) {
|
||||||
|
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
|
||||||
|
if err := ensureSolanaTransferNotBeforeOrder(0, order); err == nil {
|
||||||
|
t.Fatal("expected missing block time to fail")
|
||||||
|
}
|
||||||
|
if err := ensureSolanaTransferNotBeforeOrder(time.Now().Add(time.Minute).Unix(), order); err != nil {
|
||||||
|
t.Fatalf("expected future block time to pass: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeEvmReader struct {
|
||||||
|
receipt *types.Receipt
|
||||||
|
receiptErr error
|
||||||
|
headers map[string]*types.Header
|
||||||
|
headerErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEvmReader) TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) {
|
||||||
|
if f.receiptErr != nil {
|
||||||
|
return nil, f.receiptErr
|
||||||
|
}
|
||||||
|
return f.receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEvmReader) HeaderByNumber(_ context.Context, number *big.Int) (*types.Header, error) {
|
||||||
|
if f.headerErr != nil {
|
||||||
|
return nil, f.headerErr
|
||||||
|
}
|
||||||
|
key := "latest"
|
||||||
|
if number != nil {
|
||||||
|
key = number.String()
|
||||||
|
}
|
||||||
|
header := f.headers[key]
|
||||||
|
if header == nil {
|
||||||
|
return nil, fmt.Errorf("missing header %s", key)
|
||||||
|
}
|
||||||
|
return header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func manualTronTransactionFromCallData(t *testing.T, contractHex, recipientHex string, amount *big.Int) manualTronTransaction {
|
||||||
|
t.Helper()
|
||||||
|
body := strings.TrimPrefix(recipientHex, "41")
|
||||||
|
raw := fmt.Sprintf(`{
|
||||||
|
"raw_data": {
|
||||||
|
"contract": [{
|
||||||
|
"type": "TriggerSmartContract",
|
||||||
|
"parameter": {
|
||||||
|
"value": {
|
||||||
|
"contract_address": %q,
|
||||||
|
"data": %q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}`, contractHex, "a9059cbb"+strings.Repeat("0", 24)+body+fmt.Sprintf("%064x", amount))
|
||||||
|
var tx manualTronTransaction
|
||||||
|
if err := json.Unmarshal([]byte(raw), &tx); err != nil {
|
||||||
|
t.Fatalf("unmarshal tron tx: %v", err)
|
||||||
|
}
|
||||||
|
return tx
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
|
"github.com/dromara/carbon/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedTestAptosRPCNode(t *testing.T, url, purpose, status string, weight int) mdb.RpcNode {
|
||||||
|
t.Helper()
|
||||||
|
node := mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkAptos,
|
||||||
|
Url: url,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Weight: weight,
|
||||||
|
Enabled: true,
|
||||||
|
Purpose: purpose,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
if node.Purpose == "" {
|
||||||
|
node.Purpose = mdb.RpcNodePurposeGeneral
|
||||||
|
}
|
||||||
|
if node.Status == "" {
|
||||||
|
node.Status = mdb.RpcNodeStatusOk
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&node).Error; err != nil {
|
||||||
|
t.Fatalf("seed aptos rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAptosGetLedgerVersionUsesRpcNodes(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
data.ResetRpcFailoverForTest()
|
||||||
|
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||||
|
|
||||||
|
var disabledCalls int
|
||||||
|
disabled := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
disabledCalls++
|
||||||
|
_, _ = w.Write([]byte(`{"ledger_version":"999"}`))
|
||||||
|
}))
|
||||||
|
defer disabled.Close()
|
||||||
|
disabledNode := mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkAptos,
|
||||||
|
Url: disabled.URL,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Weight: 1000,
|
||||||
|
Enabled: false,
|
||||||
|
Purpose: mdb.RpcNodePurposeGeneral,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&disabledNode).Error; err != nil {
|
||||||
|
t.Fatalf("seed disabled aptos rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Model(&mdb.RpcNode{}).Where("id = ?", disabledNode.ID).Update("enabled", false).Error; err != nil {
|
||||||
|
t.Fatalf("disable aptos rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var calls int
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls++
|
||||||
|
if r.URL.Path != "/v1" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"ledger_version":"123"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
seedTestAptosRPCNode(t, server.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
|
||||||
|
|
||||||
|
got, err := AptosGetLedgerVersion()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AptosGetLedgerVersion(): %v", err)
|
||||||
|
}
|
||||||
|
if got != 123 {
|
||||||
|
t.Fatalf("ledger version = %d, want 123", got)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("rpc calls = %d, want 1", calls)
|
||||||
|
}
|
||||||
|
if disabledCalls != 0 {
|
||||||
|
t.Fatalf("disabled rpc calls = %d, want 0", disabledCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAptosGetTransactionsFallsBackToAlternateRpcNode(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
data.ResetRpcFailoverForTest()
|
||||||
|
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||||
|
|
||||||
|
var primaryCalls int
|
||||||
|
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
primaryCalls++
|
||||||
|
http.Error(w, "temporary failure", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer primary.Close()
|
||||||
|
var backupCalls int
|
||||||
|
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
backupCalls++
|
||||||
|
if r.URL.Path != "/v1/transactions" || r.URL.Query().Get("start") != "10" || r.URL.Query().Get("limit") != "2" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
}))
|
||||||
|
defer backup.Close()
|
||||||
|
seedTestAptosRPCNode(t, primary.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
|
||||||
|
seedTestAptosRPCNode(t, backup.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 1)
|
||||||
|
|
||||||
|
body, err := AptosGetTransactions(10, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AptosGetTransactions(): %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != "[]" {
|
||||||
|
t.Fatalf("transactions body = %s, want []", string(body))
|
||||||
|
}
|
||||||
|
if primaryCalls != 1 || backupCalls != 1 {
|
||||||
|
t.Fatalf("rpc calls primary=%d backup=%d, want 1/1", primaryCalls, backupCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManualAptosPaymentUsesManualRpcFallback(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.Chain{Network: mdb.NetworkAptos, Enabled: true, MinConfirmations: 1}).Error; err != nil {
|
||||||
|
t.Fatalf("create aptos chain: %v", err)
|
||||||
|
}
|
||||||
|
const usdc = "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
|
||||||
|
upsertTestChainToken(t, mdb.ChainToken{
|
||||||
|
Network: mdb.NetworkAptos,
|
||||||
|
Symbol: "USDC",
|
||||||
|
ContractAddress: usdc,
|
||||||
|
Decimals: 6,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
receive, err := addressutil.NormalizeMoveAddress("0xa")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize aptos receive: %v", err)
|
||||||
|
}
|
||||||
|
store, err := addressutil.NormalizeMoveAddress("0x31")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize aptos store: %v", err)
|
||||||
|
}
|
||||||
|
senderStore, err := addressutil.NormalizeMoveAddress("0x32")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize aptos sender store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var generalCalls int
|
||||||
|
general := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
generalCalls++
|
||||||
|
http.Error(w, "general unavailable", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer general.Close()
|
||||||
|
var manualCalls int
|
||||||
|
manual := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
manualCalls++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(r.URL.Path, "/v1/transactions/by_hash/"):
|
||||||
|
_, _ = w.Write([]byte(`{"type":"user_transaction","success":true,"hash":"0xabc","version":"101","timestamp":"1700000000123456","payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdc + `"},"` + receive + `","4200000"],"type":"entry_function_payload"},"events":[{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4200000","store":"` + senderStore + `"}},{"type":"0x1::fungible_asset::Deposit","data":{"amount":"4200000","store":"` + store + `"}}],"changes":[{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}]}`))
|
||||||
|
case r.URL.Path == "/v1":
|
||||||
|
_, _ = w.Write([]byte(`{"ledger_version":"101"}`))
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer manual.Close()
|
||||||
|
seedTestAptosRPCNode(t, general.URL, mdb.RpcNodePurposeGeneral, mdb.RpcNodeStatusOk, 100)
|
||||||
|
seedTestAptosRPCNode(t, manual.URL, mdb.RpcNodePurposeManualVerify, mdb.RpcNodeStatusOk, 1)
|
||||||
|
|
||||||
|
order := &mdb.Orders{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(1699999999000))},
|
||||||
|
Network: mdb.NetworkAptos,
|
||||||
|
Token: "USDC",
|
||||||
|
ActualAmount: 4.2,
|
||||||
|
ReceiveAddress: receive,
|
||||||
|
}
|
||||||
|
got, err := ValidateManualAptosPayment(order, "ABC")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateManualAptosPayment(): %v", err)
|
||||||
|
}
|
||||||
|
if got != "0xabc" {
|
||||||
|
t.Fatalf("canonical tx id = %q, want %q", got, "0xabc")
|
||||||
|
}
|
||||||
|
if generalCalls != 1 || manualCalls != 2 {
|
||||||
|
t.Fatalf("rpc calls general=%d manual=%d, want 1/2", generalCalls, manualCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
var gProcessedMoveTx sync.Map // transfer-level key -> unix timestamp
|
||||||
|
|
||||||
|
var processMoveOrder = OrderProcessing
|
||||||
|
|
||||||
|
type MoveObservedTransfer struct {
|
||||||
|
Network string
|
||||||
|
ReceiveAddress string
|
||||||
|
Token string
|
||||||
|
RawAmount *big.Int
|
||||||
|
Decimals int
|
||||||
|
MinAmount float64
|
||||||
|
Amount float64
|
||||||
|
TxID string
|
||||||
|
BlockTimeMs int64
|
||||||
|
Version int64
|
||||||
|
TransferKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProcessMoveObservedTransferResult(transfer MoveObservedTransfer) error {
|
||||||
|
net := moveLogLabel(transfer.Network)
|
||||||
|
cacheKey := MoveTransferCacheKey(transfer)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
if _, loaded := gProcessedMoveTx.LoadOrStore(cacheKey, now); loaded {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clearCache := func() {
|
||||||
|
gProcessedMoveTx.Delete(cacheKey)
|
||||||
|
}
|
||||||
|
if transfer.RawAmount == nil || transfer.RawAmount.Sign() <= 0 || transfer.Amount <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if transfer.MinAmount > 0 && transfer.Amount < transfer.MinAmount {
|
||||||
|
log.Sugar.Debugf("[%s-%s][%s] skip below min amount tx=%s amount=%.2f min=%.2f", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, transfer.Amount, transfer.MinAmount)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(transfer.Network, transfer.ReceiveAddress, transfer.Token, transfer.Amount)
|
||||||
|
if err != nil {
|
||||||
|
clearCache()
|
||||||
|
log.Sugar.Errorf("[%s-%s][%s] lock lookup failed tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tradeID == "" {
|
||||||
|
raw := ""
|
||||||
|
if transfer.RawAmount != nil {
|
||||||
|
raw = transfer.RawAmount.String()
|
||||||
|
}
|
||||||
|
log.Sugar.Infof("[%s-%s][%s] skip unmatched tx=%s amount=%.8f raw=%s decimals=%d", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, transfer.Amount, raw, transfer.Decimals)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
clearCache()
|
||||||
|
log.Sugar.Errorf("[%s-%s][%s] load order trade_id=%s tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := EnsureMoveTransferMatchesOrder(order, transfer); err != nil {
|
||||||
|
log.Sugar.Warnf("[%s-%s][%s] skip tx=%s trade_id=%s: %v", net, transfer.Token, transfer.ReceiveAddress, transfer.TxID, tradeID, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
req := &request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: transfer.ReceiveAddress,
|
||||||
|
Token: transfer.Token,
|
||||||
|
Network: transfer.Network,
|
||||||
|
TradeId: tradeID,
|
||||||
|
Amount: transfer.Amount,
|
||||||
|
BlockTransactionId: transfer.TxID,
|
||||||
|
}
|
||||||
|
if err = processMoveOrder(req); err != nil {
|
||||||
|
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||||
|
log.Sugar.Infof("[%s-%s][%s] already resolved trade_id=%s tx=%s reason=%v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
clearCache()
|
||||||
|
log.Sugar.Errorf("[%s-%s][%s] OrderProcessing failed trade_id=%s tx=%s: %v", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sendPaymentNotification(order)
|
||||||
|
log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s tx=%s", net, transfer.Token, transfer.ReceiveAddress, tradeID, transfer.TxID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MoveTransferCacheKey(transfer MoveObservedTransfer) string {
|
||||||
|
if transfer.TransferKey != "" {
|
||||||
|
return strings.ToLower(strings.TrimSpace(transfer.TransferKey))
|
||||||
|
}
|
||||||
|
receive := strings.ToLower(strings.TrimSpace(transfer.ReceiveAddress))
|
||||||
|
token := strings.ToUpper(strings.TrimSpace(transfer.Token))
|
||||||
|
raw := ""
|
||||||
|
if transfer.RawAmount != nil {
|
||||||
|
raw = transfer.RawAmount.String()
|
||||||
|
}
|
||||||
|
switch strings.ToLower(strings.TrimSpace(transfer.Network)) {
|
||||||
|
case mdb.NetworkAptos:
|
||||||
|
return fmt.Sprintf("aptos:%d:%s:%s:%s", transfer.Version, receive, token, raw)
|
||||||
|
default:
|
||||||
|
return strings.ToLower(strings.TrimSpace(transfer.Network)) + ":" + strings.TrimSpace(transfer.TxID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureMoveTransferMatchesOrder(order *mdb.Orders, transfer MoveObservedTransfer) error {
|
||||||
|
if order == nil || order.ID == 0 {
|
||||||
|
return fmt.Errorf("order not found")
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(order.Network)) != strings.ToLower(strings.TrimSpace(transfer.Network)) {
|
||||||
|
return fmt.Errorf("network mismatch")
|
||||||
|
}
|
||||||
|
orderAddr, err := addressutil.NormalizeMoveAddress(order.ReceiveAddress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid order receive address: %w", err)
|
||||||
|
}
|
||||||
|
transferAddr, err := addressutil.NormalizeMoveAddress(transfer.ReceiveAddress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid transfer receive address: %w", err)
|
||||||
|
}
|
||||||
|
if orderAddr != transferAddr {
|
||||||
|
return fmt.Errorf("transaction recipient mismatch")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(order.Token, transfer.Token) {
|
||||||
|
return fmt.Errorf("token mismatch")
|
||||||
|
}
|
||||||
|
if transfer.BlockTimeMs <= 0 {
|
||||||
|
return fmt.Errorf("transaction block time missing")
|
||||||
|
}
|
||||||
|
if transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() {
|
||||||
|
return fmt.Errorf("transaction predates the order")
|
||||||
|
}
|
||||||
|
if !amountMatchesRaw(order.ActualAmount, transfer.RawAmount, transfer.Decimals) {
|
||||||
|
return fmt.Errorf("transaction amount mismatch")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveWalletWatched(address string, wallets map[string]struct{}) bool {
|
||||||
|
if address == "" || len(wallets) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := wallets[address]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveAptosTransferKey(version int64, eventIndex int, receive, token string, raw *big.Int) string {
|
||||||
|
rawText := ""
|
||||||
|
if raw != nil {
|
||||||
|
rawText = raw.String()
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("aptos:%d:%d:%s:%s:%s", version, eventIndex, strings.ToLower(strings.TrimSpace(receive)), strings.ToUpper(strings.TrimSpace(token)), rawText)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveMoveToken(network, assetID string, tokens []mdb.ChainToken) *mdb.ChainToken {
|
||||||
|
assetID = addressutil.NormalizeMoveAssetID(assetID)
|
||||||
|
for i := range tokens {
|
||||||
|
contract := strings.TrimSpace(tokens[i].ContractAddress)
|
||||||
|
if contract == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if addressutil.MoveAssetMatches(assetID, contract) {
|
||||||
|
return &tokens[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstMoveAddress(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if normalized, err := addressutil.NormalizeMoveAddress(v); err == nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, v := range values {
|
||||||
|
if strings.TrimSpace(v) != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSignedBigInt(raw string) (*big.Int, bool) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
out, ok := new(big.Int).SetString(raw, 10)
|
||||||
|
return out, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func rawToFloat(raw *big.Int, decimals int) float64 {
|
||||||
|
if raw == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
value, _ := new(big.Rat).SetFrac(new(big.Int).Set(raw), new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(decimals)), nil)).Float64()
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordMoveRPCResult(network string, errp *error) {
|
||||||
|
if errp != nil && *errp != nil {
|
||||||
|
data.RecordRpcFailure(network)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.RecordRpcSuccess(network)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveLogLabel(network string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(network)) {
|
||||||
|
case mdb.NetworkAptos:
|
||||||
|
return "APTOS"
|
||||||
|
default:
|
||||||
|
return strings.ToUpper(network)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
|
"github.com/dromara/carbon/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseAptosTransfersUSDTAndUSDCFungibleEvents(t *testing.T) {
|
||||||
|
receive, _ := addressutil.NormalizeMoveAddress("0xa")
|
||||||
|
receive2, _ := addressutil.NormalizeMoveAddress("0xb")
|
||||||
|
usdc := "0xbae207659db88bea0cbead6da0ed00aac12edcdda169e591cd41c94180b46f3b"
|
||||||
|
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
|
||||||
|
usdcStore, _ := addressutil.NormalizeMoveAddress("0x12")
|
||||||
|
usdcSenderStore, _ := addressutil.NormalizeMoveAddress("0x13")
|
||||||
|
usdtStore, _ := addressutil.NormalizeMoveAddress("0x14")
|
||||||
|
usdtSenderStore, _ := addressutil.NormalizeMoveAddress("0x15")
|
||||||
|
noOwnerStore, _ := addressutil.NormalizeMoveAddress("0x16")
|
||||||
|
body := []byte(`[
|
||||||
|
{
|
||||||
|
"type":"user_transaction",
|
||||||
|
"success":true,
|
||||||
|
"hash":"abc",
|
||||||
|
"version":"101",
|
||||||
|
"timestamp":"1700000000123456",
|
||||||
|
"events":[
|
||||||
|
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"2500000","store":"` + usdcSenderStore + `"}},
|
||||||
|
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"2500000","store":"` + usdcStore + `"}}
|
||||||
|
],
|
||||||
|
"changes":[
|
||||||
|
{"address":"` + usdcSenderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},
|
||||||
|
{"address":"` + usdcStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"},
|
||||||
|
{"address":"` + usdcStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"0xa"}},"type":"write_resource"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"user_transaction",
|
||||||
|
"success":true,
|
||||||
|
"hash":"def",
|
||||||
|
"version":"102",
|
||||||
|
"timestamp":"1700000001123456",
|
||||||
|
"payload":{"function":"0x1::primary_fungible_store::transfer","arguments":[{"inner":"` + usdt + `"},"` + receive2 + `","3100000"],"type":"entry_function_payload"},
|
||||||
|
"events":[
|
||||||
|
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"3100000","store":"` + usdtSenderStore + `"}},
|
||||||
|
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"3100000","store":"` + usdtStore + `"}}
|
||||||
|
],
|
||||||
|
"changes":[
|
||||||
|
{"address":"` + usdtSenderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||||
|
{"address":"` + usdtStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||||
|
{"address":"` + usdtStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive2 + `"}},"type":"write_resource"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"user_transaction",
|
||||||
|
"success":true,
|
||||||
|
"hash":"skip-withdraw-only",
|
||||||
|
"version":"103",
|
||||||
|
"timestamp":"1700000002123456",
|
||||||
|
"events":[
|
||||||
|
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"4100000","metadata_address":"` + usdc + `"}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"user_transaction",
|
||||||
|
"success":true,
|
||||||
|
"hash":"skip-no-owner",
|
||||||
|
"version":"104",
|
||||||
|
"timestamp":"1700000003123456",
|
||||||
|
"events":[
|
||||||
|
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"5100000","store":"` + usdcSenderStore + `"}},
|
||||||
|
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"5100000","store":"` + noOwnerStore + `"}}
|
||||||
|
],
|
||||||
|
"changes":[
|
||||||
|
{"address":"` + noOwnerStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdc + `"}}},"type":"write_resource"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"user_transaction",
|
||||||
|
"success":false,
|
||||||
|
"hash":"skip-failed",
|
||||||
|
"events":[
|
||||||
|
{"type":"0x1::fungible_asset::Deposit","data":{"to":"` + receive + `","amount":"1","metadata_address":"` + usdc + `"}}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type":"user_transaction",
|
||||||
|
"success":true,
|
||||||
|
"hash":"skip-non-store-owner-fallback",
|
||||||
|
"version":"105",
|
||||||
|
"timestamp":"1700000004123456",
|
||||||
|
"events":[
|
||||||
|
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"6100000","metadata_address":"` + usdc + `"}},
|
||||||
|
{"type":"0x1::fungible_asset::Deposit","data":{"to":"` + receive + `","amount":"6100000","metadata_address":"` + usdc + `"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]`)
|
||||||
|
tokens := []mdb.ChainToken{
|
||||||
|
{Network: mdb.NetworkAptos, Symbol: "USDC", ContractAddress: usdc, Decimals: 6, Enabled: true, MinAmount: 1},
|
||||||
|
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true, MinAmount: 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseAptosTransfers(body, receive, tokens)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAptosTransfers: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("transfers len = %d, want 1: %#v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Token != "USDC" || got[0].Amount != 2.5 || got[0].TxID != "0xabc" || got[0].ReceiveAddress != receive {
|
||||||
|
t.Fatalf("usdc transfer = %#v", got[0])
|
||||||
|
}
|
||||||
|
if got[0].TransferKey == "" {
|
||||||
|
t.Fatalf("transfer key should be non-empty: %#v", got[0])
|
||||||
|
}
|
||||||
|
if got[0].MinAmount != 1 {
|
||||||
|
t.Fatalf("usdc min amount = %.2f, want 1", got[0].MinAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotSecondWallet, err := ParseAptosTransfers(body, receive2, tokens)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAptosTransfers second wallet: %v", err)
|
||||||
|
}
|
||||||
|
if len(gotSecondWallet) != 1 || gotSecondWallet[0].Token != "USDT" || gotSecondWallet[0].Amount != 3.1 || gotSecondWallet[0].TxID != "0xdef" {
|
||||||
|
t.Fatalf("usdt transfer = %#v", gotSecondWallet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAptosTransfersConfiguredCustomFungibleEvent(t *testing.T) {
|
||||||
|
receive, _ := addressutil.NormalizeMoveAddress("0xa")
|
||||||
|
custom := "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
store, _ := addressutil.NormalizeMoveAddress("0x21")
|
||||||
|
senderStore, _ := addressutil.NormalizeMoveAddress("0x22")
|
||||||
|
body := []byte(`[
|
||||||
|
{
|
||||||
|
"type":"user_transaction",
|
||||||
|
"success":true,
|
||||||
|
"hash":"custom",
|
||||||
|
"version":"201",
|
||||||
|
"timestamp":"1700000000123456",
|
||||||
|
"events":[
|
||||||
|
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"7500000","store":"` + senderStore + `"}},
|
||||||
|
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"7500000","store":"` + store + `"}}
|
||||||
|
],
|
||||||
|
"changes":[
|
||||||
|
{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + custom + `"}}},"type":"write_resource"},
|
||||||
|
{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + custom + `"}}},"type":"write_resource"},
|
||||||
|
{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]`)
|
||||||
|
tokens := []mdb.ChainToken{
|
||||||
|
{Network: mdb.NetworkAptos, Symbol: "MOVEUSD", ContractAddress: custom, Decimals: 6, Enabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseAptosTransfers(body, receive, tokens)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAptosTransfers: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("transfers len = %d, want 1: %#v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Token != "MOVEUSD" || got[0].Amount != 7.5 || got[0].TxID != "0xcustom" {
|
||||||
|
t.Fatalf("custom transfer = %#v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAptosTransfersSplitDepositsMatchSingleWithdraw(t *testing.T) {
|
||||||
|
receive, _ := addressutil.NormalizeMoveAddress("0xa")
|
||||||
|
receive2, _ := addressutil.NormalizeMoveAddress("0xb")
|
||||||
|
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
|
||||||
|
withdrawStore, _ := addressutil.NormalizeMoveAddress("0x21")
|
||||||
|
depositStore, _ := addressutil.NormalizeMoveAddress("0x22")
|
||||||
|
depositStore2, _ := addressutil.NormalizeMoveAddress("0x23")
|
||||||
|
body := []byte(`[
|
||||||
|
{
|
||||||
|
"type":"user_transaction",
|
||||||
|
"success":true,
|
||||||
|
"hash":"split",
|
||||||
|
"version":"201",
|
||||||
|
"timestamp":"1700000000123456",
|
||||||
|
"events":[
|
||||||
|
{"type":"0x1::fungible_asset::Withdraw","data":{"amount":"3000000","store":"` + withdrawStore + `"}},
|
||||||
|
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"1000000","store":"` + depositStore + `"}},
|
||||||
|
{"type":"0x1::fungible_asset::Deposit","data":{"amount":"2000000","store":"` + depositStore2 + `"}}
|
||||||
|
],
|
||||||
|
"changes":[
|
||||||
|
{"address":"` + withdrawStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||||
|
{"address":"` + depositStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||||
|
{"address":"` + depositStore + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"},
|
||||||
|
{"address":"` + depositStore2 + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||||
|
{"address":"` + depositStore2 + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive2 + `"}},"type":"write_resource"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]`)
|
||||||
|
tokens := []mdb.ChainToken{
|
||||||
|
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true},
|
||||||
|
}
|
||||||
|
wallets := map[string]struct{}{receive: {}, receive2: {}}
|
||||||
|
|
||||||
|
got, err := ParseAptosTransfersForWallets(body, wallets, tokens)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAptosTransfersForWallets: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 {
|
||||||
|
t.Fatalf("transfers len = %d, want 2: %#v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].Token != "USDT" || got[0].Amount != 1 || got[0].ReceiveAddress != receive || got[0].TxID != "0xsplit" {
|
||||||
|
t.Fatalf("first split transfer = %#v", got[0])
|
||||||
|
}
|
||||||
|
if got[1].Token != "USDT" || got[1].Amount != 2 || got[1].ReceiveAddress != receive2 || got[1].TxID != "0xsplit" {
|
||||||
|
t.Fatalf("second split transfer = %#v", got[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAptosTransfersUsesStoreOwnerWhenGuidAccountIsZero(t *testing.T) {
|
||||||
|
receive, _ := addressutil.NormalizeMoveAddress("0xa")
|
||||||
|
usdt := "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b"
|
||||||
|
store, _ := addressutil.NormalizeMoveAddress("0x31")
|
||||||
|
senderStore, _ := addressutil.NormalizeMoveAddress("0x32")
|
||||||
|
body := []byte(`[
|
||||||
|
{
|
||||||
|
"type":"user_transaction",
|
||||||
|
"success":true,
|
||||||
|
"hash":"decimal",
|
||||||
|
"version":"301",
|
||||||
|
"timestamp":"1700000000123456",
|
||||||
|
"events":[
|
||||||
|
{"guid":{"creation_number":"0","account_address":"0x0"},"sequence_number":"0","type":"0x1::fungible_asset::Withdraw","data":{"amount":"10000","store":"` + senderStore + `"}},
|
||||||
|
{"guid":{"creation_number":"0","account_address":"0x0"},"sequence_number":"0","type":"0x1::fungible_asset::Deposit","data":{"amount":"10000","store":"` + store + `"}}
|
||||||
|
],
|
||||||
|
"changes":[
|
||||||
|
{"address":"` + senderStore + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||||
|
{"address":"` + store + `","data":{"type":"0x1::fungible_asset::FungibleStore","data":{"metadata":{"inner":"` + usdt + `"}}},"type":"write_resource"},
|
||||||
|
{"address":"` + store + `","data":{"type":"0x1::object::ObjectCore","data":{"owner":"` + receive + `"}},"type":"write_resource"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]`)
|
||||||
|
tokens := []mdb.ChainToken{
|
||||||
|
{Network: mdb.NetworkAptos, Symbol: "USDT", ContractAddress: usdt, Decimals: 6, Enabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ParseAptosTransfers(body, receive, tokens)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAptosTransfers: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("transfers len = %d, want 1: %#v", len(got), got)
|
||||||
|
}
|
||||||
|
if got[0].ReceiveAddress != receive || got[0].Decimals != 6 || got[0].Amount != 0.01 || got[0].RawAmount.String() != "10000" {
|
||||||
|
t.Fatalf("transfer amount = %#v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureMoveTransferMatchesOrder(t *testing.T) {
|
||||||
|
receive, _ := addressutil.NormalizeMoveAddress("0xc")
|
||||||
|
order := &mdb.Orders{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||||
|
Network: mdb.NetworkAptos,
|
||||||
|
Token: "USDC",
|
||||||
|
ActualAmount: 3.5,
|
||||||
|
ReceiveAddress: strings.ToUpper(receive),
|
||||||
|
}
|
||||||
|
transfer := MoveObservedTransfer{
|
||||||
|
Network: mdb.NetworkAptos,
|
||||||
|
ReceiveAddress: receive,
|
||||||
|
Token: "USDC",
|
||||||
|
RawAmount: big.NewInt(3_500_000),
|
||||||
|
Decimals: 6,
|
||||||
|
BlockTimeMs: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
if err := EnsureMoveTransferMatchesOrder(order, transfer); err != nil {
|
||||||
|
t.Fatalf("EnsureMoveTransferMatchesOrder: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer.RawAmount = big.NewInt(3_400_000)
|
||||||
|
if err := EnsureMoveTransferMatchesOrder(order, transfer); err == nil || !strings.Contains(err.Error(), "amount") {
|
||||||
|
t.Fatalf("amount mismatch err = %v, want amount error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
type okPayCreateDepositResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
Code int `json:"code"`
|
||||||
|
Data struct {
|
||||||
|
OrderID string `json:"order_id"`
|
||||||
|
PayURL string `json:"pay_url"`
|
||||||
|
} `json:"data"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type okPayDepositOrder struct {
|
||||||
|
ProviderOrderID string
|
||||||
|
PayURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type okPayNotifyPayload struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
OrderID string `json:"order_id"`
|
||||||
|
UniqueID string `json:"unique_id"`
|
||||||
|
PayUserID string `json:"pay_user_id"`
|
||||||
|
Amount string `json:"amount"`
|
||||||
|
Coin string `json:"coin"`
|
||||||
|
PayStatus string `json:"pay_status"`
|
||||||
|
NotifyType string `json:"notify_type"`
|
||||||
|
Sign string `json:"sign"`
|
||||||
|
RawFormData string `json:"raw_form_data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func okPaySign(form map[string]string, shopID string, shopToken string) map[string]string {
|
||||||
|
values := url.Values{}
|
||||||
|
for key, value := range form {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values.Set(key, value)
|
||||||
|
}
|
||||||
|
values.Set("id", shopID)
|
||||||
|
query, _ := url.QueryUnescape(values.Encode())
|
||||||
|
sum := md5.Sum([]byte(query + "&token=" + shopToken))
|
||||||
|
|
||||||
|
signed := make(map[string]string, len(values)+1)
|
||||||
|
for key, vals := range values {
|
||||||
|
if len(vals) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
signed[key] = vals[0]
|
||||||
|
}
|
||||||
|
signed["sign"] = strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||||
|
return signed
|
||||||
|
}
|
||||||
|
|
||||||
|
func createOkPayDepositOrder(uniqueID string, amount float64, coin string, returnURL string) (*okPayDepositOrder, error) {
|
||||||
|
shopID := data.GetOkPayShopID()
|
||||||
|
shopToken := data.GetOkPayShopToken()
|
||||||
|
apiURL := strings.TrimSpace(data.GetOkPayAPIURL())
|
||||||
|
callbackURL := data.GetOkPayCallbackURL()
|
||||||
|
if shopID == "" || shopToken == "" || apiURL == "" || callbackURL == "" {
|
||||||
|
return nil, fmt.Errorf("okpay config incomplete")
|
||||||
|
}
|
||||||
|
|
||||||
|
if returnURL == "" {
|
||||||
|
returnURL = data.GetOkPayReturnURL()
|
||||||
|
}
|
||||||
|
|
||||||
|
form := map[string]string{
|
||||||
|
"unique_id": uniqueID,
|
||||||
|
"name": uniqueID,
|
||||||
|
"amount": fmt.Sprintf("%.*f", data.GetAmountPrecision(), amount),
|
||||||
|
"coin": strings.ToUpper(strings.TrimSpace(coin)),
|
||||||
|
"callback_url": callbackURL,
|
||||||
|
"return_url": returnURL,
|
||||||
|
}
|
||||||
|
form = okPaySign(form, shopID, shopToken)
|
||||||
|
|
||||||
|
base, err := url.Parse(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
base.Path = path.Join(base.Path, "payLink")
|
||||||
|
|
||||||
|
client := http_client.GetHttpClient()
|
||||||
|
client.SetTimeout(time.Duration(data.GetOkPayTimeoutSeconds()) * time.Second)
|
||||||
|
|
||||||
|
var payload okPayCreateDepositResponse
|
||||||
|
resp, err := client.R().
|
||||||
|
SetFormData(form).
|
||||||
|
SetResult(&payload).
|
||||||
|
Post(base.String())
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[okpay] create order request failed unique_id=%s api_url=%s callback_url=%s return_url=%s err=%v", uniqueID, base.String(), callbackURL, returnURL, err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode() >= 400 {
|
||||||
|
log.Sugar.Warnf("[okpay] create order http error unique_id=%s status=%s api_url=%s callback_url=%s return_url=%s body=%s", uniqueID, resp.Status(), base.String(), callbackURL, returnURL, resp.String())
|
||||||
|
return nil, fmt.Errorf("okpay http status: %s", resp.Status())
|
||||||
|
}
|
||||||
|
if payload.Data.OrderID == "" || payload.Data.PayURL == "" {
|
||||||
|
log.Sugar.Warnf("[okpay] create order rejected unique_id=%s api_url=%s callback_url=%s return_url=%s status=%s code=%d msg=%s", uniqueID, base.String(), callbackURL, returnURL, payload.Status, payload.Code, payload.Msg)
|
||||||
|
if payload.Msg != "" {
|
||||||
|
return nil, fmt.Errorf("okpay create order failed: %s (callback_url=%s)", payload.Msg, callbackURL)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("okpay create order failed (callback_url=%s)", callbackURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &okPayDepositOrder{
|
||||||
|
ProviderOrderID: payload.Data.OrderID,
|
||||||
|
PayURL: payload.Data.PayURL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyOkPayNotify(form map[string]string) bool {
|
||||||
|
shopID := data.GetOkPayShopID()
|
||||||
|
shopToken := data.GetOkPayShopToken()
|
||||||
|
signature := strings.TrimSpace(form["sign"])
|
||||||
|
if shopID == "" || shopToken == "" || signature == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if expected := okPayNotifySign(form, shopToken); expected != "" && strings.EqualFold(expected, signature) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
unsigned := make(map[string]string, len(form))
|
||||||
|
for key, value := range form {
|
||||||
|
if strings.EqualFold(key, "sign") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unsigned[key] = value
|
||||||
|
}
|
||||||
|
signed := okPaySign(unsigned, shopID, shopToken)
|
||||||
|
return strings.EqualFold(strings.TrimSpace(signed["sign"]), signature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func okPayNotifySign(form map[string]string, shopToken string) string {
|
||||||
|
orderedKeys := []string{
|
||||||
|
"code",
|
||||||
|
"data[order_id]",
|
||||||
|
"data[unique_id]",
|
||||||
|
"data[pay_user_id]",
|
||||||
|
"data[amount]",
|
||||||
|
"data[coin]",
|
||||||
|
"data[status]",
|
||||||
|
"data[type]",
|
||||||
|
"id",
|
||||||
|
"status",
|
||||||
|
}
|
||||||
|
|
||||||
|
pairs := make([]string, 0, len(orderedKeys))
|
||||||
|
for _, key := range orderedKeys {
|
||||||
|
value := strings.TrimSpace(form[key])
|
||||||
|
if value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
pairs = append(pairs, url.QueryEscape(key)+"="+url.QueryEscape(value))
|
||||||
|
}
|
||||||
|
if len(pairs) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
query, err := url.QueryUnescape(strings.Join(pairs, "&"))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := md5.Sum([]byte(query + "&token=" + shopToken))
|
||||||
|
return strings.ToUpper(hex.EncodeToString(sum[:]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOkPayNotify(form map[string]string, rawFormData string) *okPayNotifyPayload {
|
||||||
|
return &okPayNotifyPayload{
|
||||||
|
ID: strings.TrimSpace(form["id"]),
|
||||||
|
Status: strings.TrimSpace(form["status"]),
|
||||||
|
Code: strings.TrimSpace(form["code"]),
|
||||||
|
OrderID: strings.TrimSpace(form["data[order_id]"]),
|
||||||
|
UniqueID: strings.TrimSpace(form["data[unique_id]"]),
|
||||||
|
PayUserID: strings.TrimSpace(form["data[pay_user_id]"]),
|
||||||
|
Amount: strings.TrimSpace(form["data[amount]"]),
|
||||||
|
Coin: strings.TrimSpace(form["data[coin]"]),
|
||||||
|
PayStatus: strings.TrimSpace(form["data[status]"]),
|
||||||
|
NotifyType: strings.TrimSpace(form["data[type]"]),
|
||||||
|
Sign: strings.TrimSpace(form["sign"]),
|
||||||
|
RawFormData: rawFormData,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func HandleOkPayNotify(form map[string]string, rawFormData string) error {
|
||||||
|
if !verifyOkPayNotify(form) {
|
||||||
|
return fmt.Errorf("invalid okpay notify sign")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := parseOkPayNotify(form, rawFormData)
|
||||||
|
|
||||||
|
if payload.ID == "" || payload.ID != data.GetOkPayShopID() {
|
||||||
|
return fmt.Errorf("okpay notify shop id mismatch: %s", payload.ID)
|
||||||
|
}
|
||||||
|
if payload.UniqueID == "" || payload.OrderID == "" {
|
||||||
|
return fmt.Errorf("missing okpay notify identifiers")
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(payload.UniqueID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if order.ID <= 0 {
|
||||||
|
return fmt.Errorf("okpay notify order not found: %s", payload.UniqueID)
|
||||||
|
}
|
||||||
|
if order.PayProvider != "okpay" {
|
||||||
|
return fmt.Errorf("okpay notify order provider mismatch: %s", payload.UniqueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(payload.UniqueID, order.PayProvider)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if providerRow.ID <= 0 {
|
||||||
|
return fmt.Errorf("okpay provider order not found: %s", payload.UniqueID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = data.SaveProviderOrderNotify(payload.UniqueID, order.PayProvider, payload.RawFormData)
|
||||||
|
|
||||||
|
if !strings.EqualFold(payload.Status, "success") || !strings.EqualFold(payload.NotifyType, "deposit") || payload.PayStatus != "1" {
|
||||||
|
log.Sugar.Infof("[okpay] notify ignored trade_id=%s status=%s pay_status=%s type=%s", payload.UniqueID, payload.Status, payload.PayStatus, payload.NotifyType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if providerRow.ProviderOrderID != "" && providerRow.ProviderOrderID != payload.OrderID {
|
||||||
|
return fmt.Errorf("okpay provider order id mismatch: got=%s want=%s", payload.OrderID, providerRow.ProviderOrderID)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(strings.TrimSpace(order.Token), strings.TrimSpace(payload.Coin)) {
|
||||||
|
return fmt.Errorf("okpay coin mismatch: got=%s want=%s", payload.Coin, order.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyAmount, err := strconv.ParseFloat(payload.Amount, 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid okpay amount: %w", err)
|
||||||
|
}
|
||||||
|
precision := data.GetAmountPrecision()
|
||||||
|
if fmt.Sprintf("%.*f", precision, notifyAmount) != fmt.Sprintf("%.*f", precision, order.ActualAmount) {
|
||||||
|
return fmt.Errorf("okpay amount mismatch: got=%.*f want=%.*f", precision, notifyAmount, precision, order.ActualAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
Currency: order.Currency,
|
||||||
|
Token: order.Token,
|
||||||
|
Network: order.Network,
|
||||||
|
Amount: order.ActualAmount,
|
||||||
|
TradeId: order.TradeId,
|
||||||
|
BlockTransactionId: payload.OrderID,
|
||||||
|
})
|
||||||
|
processed := err == nil
|
||||||
|
if err != nil && err != constant.OrderBlockAlreadyProcess {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if processed {
|
||||||
|
sendPaymentNotification(order)
|
||||||
|
}
|
||||||
|
if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/notify"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHandleOkPayNotifySendsPaymentNotificationOnSuccess(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
const (
|
||||||
|
channelType = "test-okpay-pay-success"
|
||||||
|
shopID = "okpay-shop-notify"
|
||||||
|
shopToken = "okpay-shop-token"
|
||||||
|
tradeID = "okpay-notify-success-001"
|
||||||
|
orderID = "merchant-okpay-notify-success-001"
|
||||||
|
providerOrderID = "okpay-provider-success-001"
|
||||||
|
rawFormData = "raw-okpay-form"
|
||||||
|
)
|
||||||
|
|
||||||
|
got := make(chan string, 1)
|
||||||
|
notify.RegisterSender(channelType, func(config, text string) error {
|
||||||
|
got <- text
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.NotificationChannel{
|
||||||
|
Type: channelType,
|
||||||
|
Name: "test okpay pay success",
|
||||||
|
Config: "{}",
|
||||||
|
Events: `{"pay_success":true}`,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed notification channel: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, shopID, mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay shop id: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, shopToken, mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay shop token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: tradeID,
|
||||||
|
OrderId: orderID,
|
||||||
|
Amount: 1,
|
||||||
|
Currency: "CNY",
|
||||||
|
ActualAmount: 0.15,
|
||||||
|
ReceiveAddress: "OKPAY",
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
PaymentType: mdb.PaymentTypeEpay,
|
||||||
|
PayProvider: mdb.PaymentProviderOkPay,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("seed order: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&mdb.ProviderOrder{
|
||||||
|
TradeId: tradeID,
|
||||||
|
Provider: mdb.PaymentProviderOkPay,
|
||||||
|
ProviderOrderID: providerOrderID,
|
||||||
|
Amount: 0.15,
|
||||||
|
Coin: "USDT",
|
||||||
|
Status: mdb.ProviderOrderStatusPending,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed provider order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := okPayNotifyTestForm(shopID, shopToken, providerOrderID, tradeID, "0.15000000", "USDT")
|
||||||
|
if err := HandleOkPayNotify(form, rawFormData); err != nil {
|
||||||
|
t.Fatalf("handle okpay notify: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case text := <-got:
|
||||||
|
if !strings.Contains(text, tradeID) {
|
||||||
|
t.Fatalf("notification text = %q, want trade id %s", text, tradeID)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, orderID) {
|
||||||
|
t.Fatalf("notification text = %q, want order id %s", text, orderID)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for payment notification")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := HandleOkPayNotify(form, rawFormData); err != nil {
|
||||||
|
t.Fatalf("handle duplicate okpay notify: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case text := <-got:
|
||||||
|
t.Fatalf("duplicate notification sent: %q", text)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleOkPayNotifySettlesPlaceholderParent(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
const (
|
||||||
|
shopID = "okpay-shop-placeholder"
|
||||||
|
shopToken = "okpay-shop-placeholder-token"
|
||||||
|
parentTradeID = "okpay-placeholder-parent-001"
|
||||||
|
providerOrderID = "okpay-placeholder-provider-001"
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, shopID, mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay shop id: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, shopToken, mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay shop token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := &mdb.Orders{
|
||||||
|
TradeId: parentTradeID,
|
||||||
|
OrderId: "merchant-okpay-placeholder-parent-001",
|
||||||
|
Amount: 1,
|
||||||
|
Currency: "CNY",
|
||||||
|
ActualAmount: 0.15,
|
||||||
|
ReceiveAddress: "OKPAY",
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.PaymentProviderOkPay,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
IsSelected: true,
|
||||||
|
PaymentType: mdb.PaymentTypeGmpay,
|
||||||
|
PayProvider: mdb.PaymentProviderOkPay,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(parent).Error; err != nil {
|
||||||
|
t.Fatalf("seed parent order: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&mdb.ProviderOrder{
|
||||||
|
TradeId: parentTradeID,
|
||||||
|
Provider: mdb.PaymentProviderOkPay,
|
||||||
|
ProviderOrderID: providerOrderID,
|
||||||
|
Amount: 0.15,
|
||||||
|
Coin: "USDT",
|
||||||
|
Status: mdb.ProviderOrderStatusPending,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed provider order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := okPayNotifyTestForm(shopID, shopToken, providerOrderID, parentTradeID, "0.15000000", "USDT")
|
||||||
|
if err := HandleOkPayNotify(form, "placeholder-okpay-form"); err != nil {
|
||||||
|
t.Fatalf("handle okpay notify: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadedParent, err := data.GetOrderInfoByTradeId(parentTradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload parent: %v", err)
|
||||||
|
}
|
||||||
|
if reloadedParent.Status != mdb.StatusPaySuccess {
|
||||||
|
t.Fatalf("parent status = %d, want %d", reloadedParent.Status, mdb.StatusPaySuccess)
|
||||||
|
}
|
||||||
|
if reloadedParent.PayBySubId != 0 {
|
||||||
|
t.Fatalf("parent pay_by_sub_id = %d, want 0", reloadedParent.PayBySubId)
|
||||||
|
}
|
||||||
|
if reloadedParent.Token != "USDT" || reloadedParent.Network != mdb.PaymentProviderOkPay || reloadedParent.ReceiveAddress != "OKPAY" || reloadedParent.ActualAmount != 0.15 {
|
||||||
|
t.Fatalf("okpay parent fields changed: token=%q network=%q address=%q actual=%v", reloadedParent.Token, reloadedParent.Network, reloadedParent.ReceiveAddress, reloadedParent.ActualAmount)
|
||||||
|
}
|
||||||
|
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(parentTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload provider row: %v", err)
|
||||||
|
}
|
||||||
|
if providerRow.Status != mdb.ProviderOrderStatusPaid {
|
||||||
|
t.Fatalf("provider status = %q, want %q", providerRow.Status, mdb.ProviderOrderStatusPaid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func okPayNotifyTestForm(shopID, shopToken, providerOrderID, tradeID, amount, coin string) map[string]string {
|
||||||
|
form := map[string]string{
|
||||||
|
"code": "200",
|
||||||
|
"id": shopID,
|
||||||
|
"status": "success",
|
||||||
|
"data[order_id]": providerOrderID,
|
||||||
|
"data[unique_id]": tradeID,
|
||||||
|
"data[pay_user_id]": "123456789",
|
||||||
|
"data[amount]": amount,
|
||||||
|
"data[coin]": coin,
|
||||||
|
"data[status]": "1",
|
||||||
|
"data[type]": "deposit",
|
||||||
|
}
|
||||||
|
form["sign"] = okPayNotifySign(form, shopToken)
|
||||||
|
return form
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
cryptorand "crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/rand"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
@@ -14,9 +14,11 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/GMWalletApp/epusdt/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/GMWalletApp/epusdt/util/math"
|
"github.com/GMWalletApp/epusdt/util/math"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/security"
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
)
|
)
|
||||||
@@ -24,7 +26,6 @@ import (
|
|||||||
const (
|
const (
|
||||||
CnyMinimumPaymentAmount = 0.01
|
CnyMinimumPaymentAmount = 0.01
|
||||||
UsdtMinimumPaymentAmount = 0.01
|
UsdtMinimumPaymentAmount = 0.01
|
||||||
UsdtAmountPerIncrement = 0.01
|
|
||||||
IncrementalMaximumNumber = 100
|
IncrementalMaximumNumber = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -48,34 +49,79 @@ func normalizeOrderAddressByNetwork(network, address string) string {
|
|||||||
switch network {
|
switch network {
|
||||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||||
return strings.ToLower(address)
|
return strings.ToLower(address)
|
||||||
|
case mdb.NetworkTon:
|
||||||
|
if normalized, err := addressutil.NormalizeTonAddress(address); err == nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return address
|
||||||
|
case mdb.NetworkAptos:
|
||||||
|
if normalized, err := addressutil.NormalizeMoveAddress(address); err == nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return address
|
||||||
default:
|
default:
|
||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) {
|
||||||
|
tokenRow, err := data.GetEnabledChainTokenBySymbol(network, token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tokenRow == nil || tokenRow.ID == 0 {
|
||||||
|
return nil, constant.SupportedAssetNotFound
|
||||||
|
}
|
||||||
|
if !ChainTokenReadyForPayment(*tokenRow) {
|
||||||
|
return nil, constant.SupportedAssetNotFound
|
||||||
|
}
|
||||||
|
return tokenRow, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCreateTransactionTokenNetwork(token, network string) error {
|
||||||
|
if (token == "") != (network == "") {
|
||||||
|
return constant.ParamsMarshalErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCreateTransactionResponse(order *mdb.Orders) *response.CreateTransactionResponse {
|
||||||
|
return &response.CreateTransactionResponse{
|
||||||
|
TradeId: order.TradeId,
|
||||||
|
OrderId: order.OrderId,
|
||||||
|
Amount: order.Amount,
|
||||||
|
Currency: order.Currency,
|
||||||
|
ActualAmount: order.ActualAmount,
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
Token: order.Token,
|
||||||
|
Status: order.Status,
|
||||||
|
ExpirationTime: carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp(),
|
||||||
|
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CreateTransaction creates a new payment order.
|
// CreateTransaction creates a new payment order.
|
||||||
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
||||||
gCreateTransactionLock.Lock()
|
|
||||||
defer gCreateTransactionLock.Unlock()
|
|
||||||
|
|
||||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||||
network := strings.ToLower(strings.TrimSpace(req.Network))
|
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||||
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
|
if err := validateCreateTransactionTokenNetwork(token, network); err != nil {
|
||||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
return nil, err
|
||||||
if rate <= 0 {
|
}
|
||||||
return nil, constant.RateAmountErr
|
notifyURL := strings.TrimSpace(req.NotifyUrl)
|
||||||
|
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
|
||||||
|
return nil, constant.NotifyURLErr
|
||||||
|
}
|
||||||
|
paymentType := mdb.PaymentTypeGmpay
|
||||||
|
if strings.EqualFold(req.PaymentType, mdb.PaymentTypeEpay) {
|
||||||
|
paymentType = mdb.PaymentTypeEpay
|
||||||
}
|
}
|
||||||
|
|
||||||
decimalPayAmount := decimal.NewFromFloat(payAmount)
|
gCreateTransactionLock.Lock()
|
||||||
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
defer gCreateTransactionLock.Unlock()
|
||||||
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
|
|
||||||
return nil, constant.PayAmountErr
|
|
||||||
}
|
|
||||||
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
|
||||||
return nil, constant.PayAmountErr
|
|
||||||
}
|
|
||||||
|
|
||||||
|
amountPrecision := data.GetAmountPrecision()
|
||||||
|
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
|
||||||
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
|
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -84,9 +130,49 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
return nil, constant.OrderAlreadyExists
|
return nil, constant.OrderAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
decimalPayAmount := decimal.NewFromFloat(payAmount)
|
||||||
|
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
|
||||||
|
return nil, constant.PayAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" && network == "" {
|
||||||
|
tradeID := GenerateCode()
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: tradeID,
|
||||||
|
OrderId: req.OrderId,
|
||||||
|
Amount: payAmount,
|
||||||
|
Currency: currency,
|
||||||
|
Status: mdb.StatusWaitSelect,
|
||||||
|
NotifyUrl: notifyURL,
|
||||||
|
RedirectUrl: req.RedirectUrl,
|
||||||
|
Name: req.Name,
|
||||||
|
PaymentType: paymentType,
|
||||||
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
|
ApiKeyID: apiKeyID(apiKey),
|
||||||
|
}
|
||||||
|
if err = data.CreateOrderWithTransaction(dao.Mdb, order); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildCreateTransactionResponse(order), nil
|
||||||
|
}
|
||||||
|
|
||||||
if !data.IsChainEnabled(network) {
|
if !data.IsChainEnabled(network) {
|
||||||
return nil, constant.ChainNotEnabled
|
return nil, constant.ChainNotEnabled
|
||||||
}
|
}
|
||||||
|
if _, err = ensureEnabledOrderAsset(network, token); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
||||||
|
if rate <= 0 {
|
||||||
|
return nil, constant.RateAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||||
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||||
|
return nil, constant.PayAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -96,7 +182,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
}
|
}
|
||||||
|
|
||||||
tradeID := GenerateCode()
|
tradeID := GenerateCode()
|
||||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), amountPrecision)
|
||||||
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress)
|
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -109,17 +195,18 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
order := &mdb.Orders{
|
order := &mdb.Orders{
|
||||||
TradeId: tradeID,
|
TradeId: tradeID,
|
||||||
OrderId: req.OrderId,
|
OrderId: req.OrderId,
|
||||||
Amount: req.Amount,
|
Amount: payAmount,
|
||||||
Currency: currency,
|
Currency: currency,
|
||||||
ActualAmount: availableAmount,
|
ActualAmount: availableAmount,
|
||||||
ReceiveAddress: availableAddress,
|
ReceiveAddress: availableAddress,
|
||||||
Token: token,
|
Token: token,
|
||||||
Network: network,
|
Network: network,
|
||||||
Status: mdb.StatusWaitPay,
|
Status: mdb.StatusWaitPay,
|
||||||
NotifyUrl: req.NotifyUrl,
|
NotifyUrl: notifyURL,
|
||||||
RedirectUrl: req.RedirectUrl,
|
RedirectUrl: req.RedirectUrl,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
PaymentType: req.PaymentType,
|
PaymentType: paymentType,
|
||||||
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
ApiKeyID: apiKeyID(apiKey),
|
ApiKeyID: apiKeyID(apiKey),
|
||||||
}
|
}
|
||||||
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
|
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
|
||||||
@@ -133,19 +220,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
|
return buildCreateTransactionResponse(order), nil
|
||||||
resp := &response.CreateTransactionResponse{
|
|
||||||
TradeId: order.TradeId,
|
|
||||||
OrderId: order.OrderId,
|
|
||||||
Amount: order.Amount,
|
|
||||||
Currency: order.Currency,
|
|
||||||
ActualAmount: order.ActualAmount,
|
|
||||||
ReceiveAddress: order.ReceiveAddress,
|
|
||||||
Token: order.Token,
|
|
||||||
ExpirationTime: expirationTime,
|
|
||||||
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
|
|
||||||
}
|
|
||||||
return resp, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderProcessing marks an order as paid and releases its sqlite reservation.
|
// OrderProcessing marks an order as paid and releases its sqlite reservation.
|
||||||
@@ -178,15 +253,21 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil {
|
|
||||||
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load order to check parent-child relationship
|
// Load order to check parent-child relationship
|
||||||
order, err := data.GetOrderInfoByTradeId(req.TradeId)
|
order, err := data.GetOrderInfoByTradeId(req.TradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if strings.TrimSpace(req.Network) != "" && strings.TrimSpace(req.ReceiveAddress) != "" && strings.TrimSpace(req.Token) != "" && req.Amount > 0 {
|
||||||
|
if unlockErr := data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); unlockErr != nil {
|
||||||
|
log.Sugar.Warnf("[order] fallback unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, unlockErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
|
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
|
||||||
}
|
}
|
||||||
|
if hasTransactionLock(order) {
|
||||||
|
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
||||||
|
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", order.TradeId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parent order paid directly: expire all sub-orders and release their locks
|
// Parent order paid directly: expire all sub-orders and release their locks
|
||||||
if order.ParentTradeId == "" {
|
if order.ParentTradeId == "" {
|
||||||
@@ -199,8 +280,15 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil {
|
if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil {
|
||||||
log.Sugar.Warnf("[order] expire sub-order failed, trade_id=%s, err=%v", sub.TradeId, err)
|
log.Sugar.Warnf("[order] expire sub-order failed, trade_id=%s, err=%v", sub.TradeId, err)
|
||||||
}
|
}
|
||||||
if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil {
|
if sub.PayProvider != "" && sub.PayProvider != mdb.PaymentProviderOnChain {
|
||||||
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
|
if err = data.MarkProviderOrderExpired(sub.TradeId, sub.PayProvider); err != nil {
|
||||||
|
log.Sugar.Warnf("[order] expire provider order failed, trade_id=%s, provider=%s, err=%v", sub.TradeId, sub.PayProvider, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasTransactionLock(&sub) {
|
||||||
|
if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil {
|
||||||
|
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -251,14 +339,23 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Release parent's own wallet lock
|
// Release parent's own wallet lock
|
||||||
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil {
|
if hasTransactionLock(parent) {
|
||||||
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil {
|
||||||
|
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release sibling locks after their status transitions commit.
|
// Release sibling locks after their status transitions commit.
|
||||||
for _, sib := range siblings {
|
for _, sib := range siblings {
|
||||||
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
|
if sib.PayProvider != "" && sib.PayProvider != mdb.PaymentProviderOnChain {
|
||||||
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
|
if err = data.MarkProviderOrderExpired(sib.TradeId, sib.PayProvider); err != nil {
|
||||||
|
log.Sugar.Warnf("[order] expire sibling provider order failed, trade_id=%s, provider=%s, err=%v", sib.TradeId, sib.PayProvider, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasTransactionLock(&sib) {
|
||||||
|
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
|
||||||
|
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,6 +366,7 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
|
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
|
||||||
availableAddress := ""
|
availableAddress := ""
|
||||||
availableAmount := amount
|
availableAmount := amount
|
||||||
|
amountPrecision := data.GetAmountPrecision()
|
||||||
|
|
||||||
tryLockWalletFunc := func(targetAmount float64) (string, error) {
|
tryLockWalletFunc := func(targetAmount float64) (string, error) {
|
||||||
for _, address := range walletAddress {
|
for _, address := range walletAddress {
|
||||||
@@ -292,8 +390,8 @@ func ReserveAvailableWalletAndAmount(tradeID string, network string, token strin
|
|||||||
}
|
}
|
||||||
if address == "" {
|
if address == "" {
|
||||||
decimalOldAmount := decimal.NewFromFloat(availableAmount)
|
decimalOldAmount := decimal.NewFromFloat(availableAmount)
|
||||||
decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement)
|
decimalIncr := decimal.New(1, int32(-amountPrecision))
|
||||||
availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64()
|
availableAmount = math.MustParsePrecFloat64(decimalOldAmount.Add(decimalIncr).InexactFloat64(), amountPrecision)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
availableAddress = address
|
availableAddress = address
|
||||||
@@ -304,9 +402,11 @@ func ReserveAvailableWalletAndAmount(tradeID string, network string, token strin
|
|||||||
|
|
||||||
// GenerateCode creates a unique trade id.
|
// GenerateCode creates a unique trade id.
|
||||||
func GenerateCode() string {
|
func GenerateCode() string {
|
||||||
date := time.Now().Format("20060102")
|
buf := make([]byte, 18)
|
||||||
r := rand.Intn(1000)
|
if _, err := cryptorand.Read(buf); err != nil {
|
||||||
return fmt.Sprintf("%s%d%03d", date, time.Now().UnixNano()/1e6, r)
|
panic(fmt.Sprintf("generate trade id: crypto/rand failed: %v", err))
|
||||||
|
}
|
||||||
|
return base64.RawURLEncoding.EncodeToString(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetOrderInfoByTradeId returns a validated order.
|
// GetOrderInfoByTradeId returns a validated order.
|
||||||
@@ -321,9 +421,106 @@ func GetOrderInfoByTradeId(tradeId string) (*mdb.Orders, error) {
|
|||||||
return order, nil
|
return order, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const MaxSubOrders = 2
|
// SubmitManualPayment verifies a submitted transaction hash and marks the
|
||||||
|
// matching on-chain order paid using the same path as automatic chain scans.
|
||||||
|
func SubmitManualPayment(tradeId, blockTransactionId string) (*response.ManualPaymentResponse, error) {
|
||||||
|
tradeId = strings.TrimSpace(tradeId)
|
||||||
|
blockTransactionId = strings.TrimSpace(blockTransactionId)
|
||||||
|
|
||||||
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
|
order, err := GetOrderInfoByTradeId(tradeId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return submitManualPaymentForOrder(order, blockTransactionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SubmitCashierManualPayment is the public cashier variant. It rejects hashes
|
||||||
|
// already stored on any order before touching RPC so repeated/public probes do
|
||||||
|
// not spend RPC quota. Admin mark-paid intentionally keeps using
|
||||||
|
// SubmitManualPayment and the existing verification path.
|
||||||
|
func SubmitCashierManualPayment(tradeId, blockTransactionId string) (*response.ManualPaymentResponse, error) {
|
||||||
|
tradeId = strings.TrimSpace(tradeId)
|
||||||
|
blockTransactionId = strings.TrimSpace(blockTransactionId)
|
||||||
|
|
||||||
|
order, err := GetOrderInfoByTradeId(tradeId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = validateManualPaymentOrder(order); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = ensureManualBlockTransactionUnused(order, blockTransactionId); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return submitManualPaymentForOrder(order, blockTransactionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func submitManualPaymentForOrder(order *mdb.Orders, blockTransactionId string) (*response.ManualPaymentResponse, error) {
|
||||||
|
blockTransactionId = strings.TrimSpace(blockTransactionId)
|
||||||
|
if err := validateManualPaymentOrder(order); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
verifiedBlockTransactionID, err := ValidateManualOrderPayment(order, blockTransactionId)
|
||||||
|
if err != nil {
|
||||||
|
var rspErr *constant.RspError
|
||||||
|
if errors.As(err, &rspErr) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return nil, constant.ManualPaymentVerifyErr
|
||||||
|
}
|
||||||
|
if err = OrderProcessing(&request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
Currency: order.Currency,
|
||||||
|
Token: order.Token,
|
||||||
|
Network: order.Network,
|
||||||
|
Amount: order.ActualAmount,
|
||||||
|
TradeId: order.TradeId,
|
||||||
|
BlockTransactionId: verifiedBlockTransactionID,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedOrder, err := GetOrderInfoByTradeId(order.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &response.ManualPaymentResponse{
|
||||||
|
TradeId: updatedOrder.TradeId,
|
||||||
|
Status: updatedOrder.Status,
|
||||||
|
BlockTransactionId: updatedOrder.BlockTransactionId,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualPaymentOrder(order *mdb.Orders) error {
|
||||||
|
if order.Status != mdb.StatusWaitPay {
|
||||||
|
return constant.OrderNotWaitPay
|
||||||
|
}
|
||||||
|
if !isOnChainOrder(order.PayProvider) {
|
||||||
|
return constant.ManualPaymentProviderErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOnChainOrder(payProvider string) bool {
|
||||||
|
payProvider = strings.TrimSpace(payProvider)
|
||||||
|
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTransactionLock(order *mdb.Orders) bool {
|
||||||
|
return order != nil &&
|
||||||
|
isOnChainOrder(order.PayProvider) &&
|
||||||
|
strings.TrimSpace(order.Network) != "" &&
|
||||||
|
strings.TrimSpace(order.ReceiveAddress) != "" &&
|
||||||
|
strings.TrimSpace(order.Token) != "" &&
|
||||||
|
order.ActualAmount > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const MaxSubOrders = 1
|
||||||
|
|
||||||
|
// SwitchNetwork completes a status=4 placeholder in place, or creates/returns
|
||||||
|
// an existing sub-order for a different payment target once the parent is
|
||||||
|
// already concrete.
|
||||||
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
|
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
|
||||||
gCreateTransactionLock.Lock()
|
gCreateTransactionLock.Lock()
|
||||||
defer gCreateTransactionLock.Unlock()
|
defer gCreateTransactionLock.Unlock()
|
||||||
@@ -342,10 +539,18 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
if parent.ParentTradeId != "" {
|
if parent.ParentTradeId != "" {
|
||||||
return nil, constant.CannotSwitchSubOrder
|
return nil, constant.CannotSwitchSubOrder
|
||||||
}
|
}
|
||||||
if parent.Status != mdb.StatusWaitPay {
|
if parent.Status != mdb.StatusWaitPay && parent.Status != mdb.StatusWaitSelect {
|
||||||
return nil, constant.OrderNotWaitPay
|
return nil, constant.OrderNotWaitPay
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if network == mdb.PaymentProviderOkPay {
|
||||||
|
return switchToOkPay(parent, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent.Status == mdb.StatusWaitSelect {
|
||||||
|
return completeWaitSelectOrder(parent, token, network)
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Same token+network as parent → mark selected and return
|
// 2. Same token+network as parent → mark selected and return
|
||||||
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
|
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
|
||||||
_ = data.MarkOrderSelected(parent.TradeId)
|
_ = data.MarkOrderSelected(parent.TradeId)
|
||||||
@@ -366,8 +571,9 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return buildCheckoutResponse(existing), nil
|
return buildCheckoutResponse(existing), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Check sub-order limit
|
// 4. Only one sub-order may ever be created under a parent. Existing
|
||||||
count, err := data.CountActiveSubOrders(parent.TradeId)
|
// active sub-orders are reused above; any other target after that is blocked.
|
||||||
|
count, err := data.CountSubOrders(parent.TradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -375,7 +581,16 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return nil, constant.SubOrderLimitExceeded
|
return nil, constant.SubOrderLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Calculate amount for the new network
|
// 5. Validate the target chain and asset before any rate lookup so
|
||||||
|
// disabled/unknown tokens return the stable unsupported-asset error.
|
||||||
|
if !data.IsChainEnabled(network) {
|
||||||
|
return nil, constant.ChainNotEnabled
|
||||||
|
}
|
||||||
|
if _, err = ensureEnabledOrderAsset(network, token); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Calculate amount for the new network
|
||||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
||||||
if rate <= 0 {
|
if rate <= 0 {
|
||||||
return nil, constant.RateAmountErr
|
return nil, constant.RateAmountErr
|
||||||
@@ -386,10 +601,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return nil, constant.PayAmountErr
|
return nil, constant.PayAmountErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Find and lock wallet
|
// 7. Find and lock wallet
|
||||||
if !data.IsChainEnabled(network) {
|
|
||||||
return nil, constant.ChainNotEnabled
|
|
||||||
}
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -399,7 +611,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
}
|
}
|
||||||
|
|
||||||
subTradeID := GenerateCode()
|
subTradeID := GenerateCode()
|
||||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
||||||
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress)
|
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(subTradeID, network, token, amount, walletAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -408,7 +620,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return nil, constant.NotAvailableAmountErr
|
return nil, constant.NotAvailableAmountErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Create sub-order
|
// 8. Create sub-order
|
||||||
tx := dao.Mdb.Begin()
|
tx := dao.Mdb.Begin()
|
||||||
subOrder := &mdb.Orders{
|
subOrder := &mdb.Orders{
|
||||||
TradeId: subTradeID,
|
TradeId: subTradeID,
|
||||||
@@ -427,6 +639,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
Name: parent.Name,
|
Name: parent.Name,
|
||||||
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,
|
||||||
ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails
|
ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails
|
||||||
}
|
}
|
||||||
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
|
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
|
||||||
@@ -447,7 +660,63 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return buildCheckoutResponse(subOrder), nil
|
return buildCheckoutResponse(subOrder), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func completeWaitSelectOrder(parent *mdb.Orders, token string, network string) (*response.CheckoutCounterResponse, error) {
|
||||||
|
if !data.IsChainEnabled(network) {
|
||||||
|
return nil, constant.ChainNotEnabled
|
||||||
|
}
|
||||||
|
if _, err := ensureEnabledOrderAsset(network, token); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
||||||
|
if rate <= 0 {
|
||||||
|
return nil, constant.RateAmountErr
|
||||||
|
}
|
||||||
|
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
||||||
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||||
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||||
|
return nil, constant.PayAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(walletAddress) <= 0 {
|
||||||
|
return nil, constant.NotAvailableWalletAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
||||||
|
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(parent.TradeId, network, token, amount, walletAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if availableAddress == "" {
|
||||||
|
return nil, constant.NotAvailableAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := data.CompleteWaitSelectOrder(parent.TradeId, network, token, availableAddress, availableAmount)
|
||||||
|
if err != nil {
|
||||||
|
_ = data.UnLockTransactionByTradeId(parent.TradeId)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
_ = data.UnLockTransactionByTradeId(parent.TradeId)
|
||||||
|
return nil, constant.OrderStatusConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildCheckoutResponse(order), nil
|
||||||
|
}
|
||||||
|
|
||||||
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
|
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
|
||||||
|
paymentType := mdb.PaymentTypeGmpay
|
||||||
|
if isEPayOrder(order) {
|
||||||
|
paymentType = mdb.PaymentTypeEpay
|
||||||
|
}
|
||||||
return &response.CheckoutCounterResponse{
|
return &response.CheckoutCounterResponse{
|
||||||
TradeId: order.TradeId,
|
TradeId: order.TradeId,
|
||||||
Amount: order.Amount,
|
Amount: order.Amount,
|
||||||
@@ -456,9 +725,268 @@ func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse
|
|||||||
Currency: order.Currency,
|
Currency: order.Currency,
|
||||||
ReceiveAddress: order.ReceiveAddress,
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
Network: order.Network,
|
Network: order.Network,
|
||||||
|
Status: order.Status,
|
||||||
|
PaymentType: strings.ToLower(paymentType),
|
||||||
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
||||||
RedirectUrl: order.RedirectUrl,
|
RedirectUrl: buildPublicRedirectURL(order),
|
||||||
CreatedAt: order.CreatedAt.TimestampMilli(),
|
CreatedAt: order.CreatedAt.TimestampMilli(),
|
||||||
IsSelected: order.IsSelected,
|
IsSelected: order.IsSelected,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func completeWaitSelectOkPayOrder(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
|
||||||
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
||||||
|
if rate <= 0 {
|
||||||
|
return nil, constant.RateAmountErr
|
||||||
|
}
|
||||||
|
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
||||||
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||||
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||||
|
return nil, constant.PayAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
||||||
|
returnURL := strings.TrimSpace(parent.RedirectUrl)
|
||||||
|
if returnURL == "" {
|
||||||
|
returnURL = data.GetOkPayReturnURL()
|
||||||
|
}
|
||||||
|
if returnURL == "" {
|
||||||
|
returnURL = fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), parent.TradeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := dao.Mdb.Begin()
|
||||||
|
providerRow := &mdb.ProviderOrder{
|
||||||
|
TradeId: parent.TradeId,
|
||||||
|
Provider: mdb.PaymentProviderOkPay,
|
||||||
|
ProviderOrderID: "",
|
||||||
|
PayURL: "",
|
||||||
|
Amount: amount,
|
||||||
|
Coin: token,
|
||||||
|
Status: mdb.ProviderOrderStatusCreating,
|
||||||
|
NotifyRaw: "",
|
||||||
|
}
|
||||||
|
if err := data.UpsertProviderOrderCreatingWithTransaction(tx, providerRow); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
okpayOrder, err := createOkPayDepositOrder(parent.TradeId, amount, token, returnURL)
|
||||||
|
if err != nil {
|
||||||
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
return nil, constant.PaymentProviderCreateErr
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeTx := dao.Mdb.Begin()
|
||||||
|
if err = data.UpdateProviderOrderCreatedWithTransaction(finalizeTx, parent.TradeId, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updated, err := data.CompleteWaitSelectOkPayOrderWithTransaction(finalizeTx, parent.TradeId, token, amount)
|
||||||
|
if err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
return nil, constant.OrderStatusConflict
|
||||||
|
}
|
||||||
|
if err = finalizeTx.Commit().Error; err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := buildCheckoutResponse(order)
|
||||||
|
resp.PaymentUrl = okpayOrder.PayURL
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
|
||||||
|
if !data.GetOkPayEnabled() {
|
||||||
|
return nil, constant.PaymentProviderNotEnabled
|
||||||
|
}
|
||||||
|
if data.GetOkPayShopID() == "" || data.GetOkPayShopToken() == "" || data.GetOkPayAPIURL() == "" || data.GetOkPayCallbackURL() == "" {
|
||||||
|
return nil, constant.PaymentProviderConfigErr
|
||||||
|
}
|
||||||
|
if !okPayTokenAllowed(token) {
|
||||||
|
return nil, constant.PaymentProviderNotSupport
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent.Status == mdb.StatusWaitSelect {
|
||||||
|
return completeWaitSelectOkPayOrder(parent, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent.PayProvider == mdb.PaymentProviderOkPay && strings.EqualFold(parent.Token, token) {
|
||||||
|
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
|
||||||
|
return nil, constant.SystemErr
|
||||||
|
}
|
||||||
|
_ = data.MarkOrderSelected(parent.TradeId)
|
||||||
|
_ = data.RefreshOrderExpiration(parent.TradeId)
|
||||||
|
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := buildCheckoutResponse(order)
|
||||||
|
resp.PaymentUrl = providerRow.PayURL
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := data.GetSubOrderByTokenPayProvider(parent.TradeId, token, mdb.PaymentProviderOkPay)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if existing.ID > 0 {
|
||||||
|
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(existing.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
|
||||||
|
return nil, constant.SystemErr
|
||||||
|
}
|
||||||
|
updated, markErr := data.MarkProviderSwitchParentSelectedWithTransaction(dao.Mdb, parent.TradeId)
|
||||||
|
if markErr != nil {
|
||||||
|
return nil, markErr
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
return nil, constant.OrderStatusConflict
|
||||||
|
}
|
||||||
|
_ = data.MarkOrderSelected(existing.TradeId)
|
||||||
|
existing.IsSelected = true
|
||||||
|
resp := buildCheckoutResponse(existing)
|
||||||
|
resp.PaymentUrl = providerRow.PayURL
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := data.CountSubOrders(parent.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if count >= MaxSubOrders {
|
||||||
|
return nil, constant.SubOrderLimitExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
||||||
|
if rate <= 0 {
|
||||||
|
return nil, constant.RateAmountErr
|
||||||
|
}
|
||||||
|
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
||||||
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||||
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||||
|
return nil, constant.PayAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
subTradeID := GenerateCode()
|
||||||
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
||||||
|
returnURL := strings.TrimSpace(parent.RedirectUrl)
|
||||||
|
if returnURL == "" {
|
||||||
|
returnURL = data.GetOkPayReturnURL()
|
||||||
|
}
|
||||||
|
if returnURL == "" {
|
||||||
|
returnURL = fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), parent.TradeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := dao.Mdb.Begin()
|
||||||
|
subOrder := &mdb.Orders{
|
||||||
|
TradeId: subTradeID,
|
||||||
|
OrderId: subTradeID,
|
||||||
|
ParentTradeId: parent.TradeId,
|
||||||
|
Amount: parent.Amount,
|
||||||
|
Currency: parent.Currency,
|
||||||
|
ActualAmount: amount,
|
||||||
|
ReceiveAddress: "OKPAY",
|
||||||
|
Token: token,
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
IsSelected: true,
|
||||||
|
NotifyUrl: "",
|
||||||
|
RedirectUrl: parent.RedirectUrl,
|
||||||
|
Name: parent.Name,
|
||||||
|
CallBackConfirm: mdb.CallBackConfirmOk,
|
||||||
|
PaymentType: parent.PaymentType,
|
||||||
|
PayProvider: mdb.PaymentProviderOkPay,
|
||||||
|
ApiKeyID: parent.ApiKeyID,
|
||||||
|
}
|
||||||
|
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
providerRow := &mdb.ProviderOrder{
|
||||||
|
TradeId: subTradeID,
|
||||||
|
Provider: mdb.PaymentProviderOkPay,
|
||||||
|
ProviderOrderID: "",
|
||||||
|
PayURL: "",
|
||||||
|
Amount: amount,
|
||||||
|
Coin: token,
|
||||||
|
Status: mdb.ProviderOrderStatusCreating,
|
||||||
|
}
|
||||||
|
if err = data.CreateProviderOrderWithTransaction(tx, providerRow); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit().Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
okpayOrder, err := createOkPayDepositOrder(subTradeID, amount, token, returnURL)
|
||||||
|
if err != nil {
|
||||||
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
|
return nil, constant.PaymentProviderCreateErr
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeTx := dao.Mdb.Begin()
|
||||||
|
if err = data.UpdateProviderOrderCreatedWithTransaction(finalizeTx, subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updated, err := data.MarkProviderSwitchParentSelectedWithTransaction(finalizeTx, parent.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
|
return nil, constant.OrderStatusConflict
|
||||||
|
}
|
||||||
|
if err = finalizeTx.Commit().Error; err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := buildCheckoutResponse(subOrder)
|
||||||
|
resp.PaymentUrl = okpayOrder.PayURL
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func okPayTokenAllowed(token string) bool {
|
||||||
|
for _, item := range data.GetOkPayAllowTokens() {
|
||||||
|
if strings.EqualFold(item, token) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,38 +1,34 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/config"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrOrder = errors.New("不存在待支付订单或已过期")
|
// ErrOrder is returned when checkout initialization cannot find the trade id.
|
||||||
|
var ErrOrder = constant.OrderNotExists
|
||||||
|
|
||||||
// GetCheckoutCounterByTradeId returns checkout info for a pending order.
|
// GetCheckoutCounterByTradeId returns checkout initialization data for an existing order.
|
||||||
|
// It does not decide the payment state; callers should use CheckStatus for that.
|
||||||
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
|
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
|
||||||
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
|
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
|
if orderInfo.ID <= 0 {
|
||||||
return nil, ErrOrder
|
return nil, ErrOrder
|
||||||
}
|
}
|
||||||
|
resp := buildCheckoutResponse(orderInfo)
|
||||||
resp := &response.CheckoutCounterResponse{
|
if orderInfo.PayProvider == mdb.PaymentProviderOkPay {
|
||||||
TradeId: orderInfo.TradeId,
|
providerRow, rowErr := data.GetProviderOrderByTradeIDAndProvider(orderInfo.TradeId, mdb.PaymentProviderOkPay)
|
||||||
Amount: orderInfo.Amount,
|
if rowErr != nil {
|
||||||
ActualAmount: orderInfo.ActualAmount,
|
return nil, rowErr
|
||||||
Token: orderInfo.Token,
|
}
|
||||||
Currency: orderInfo.Currency,
|
if providerRow.ID > 0 {
|
||||||
ReceiveAddress: orderInfo.ReceiveAddress,
|
resp.PaymentUrl = providerRow.PayURL
|
||||||
Network: orderInfo.Network,
|
}
|
||||||
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
|
||||||
RedirectUrl: orderInfo.RedirectUrl,
|
|
||||||
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
|
|
||||||
IsSelected: orderInfo.IsSelected,
|
|
||||||
}
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ChainTokenReadyForPayment(token mdb.ChainToken) bool {
|
||||||
|
if strings.TrimSpace(token.Symbol) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if chainTokenRequiresAssetID(token) && strings.TrimSpace(token.ContractAddress) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func chainTokenRequiresAssetID(token mdb.ChainToken) bool {
|
||||||
|
network := strings.ToLower(strings.TrimSpace(token.Network))
|
||||||
|
symbol := strings.ToUpper(strings.TrimSpace(token.Symbol))
|
||||||
|
switch network {
|
||||||
|
case mdb.NetworkTron:
|
||||||
|
return symbol != "TRX"
|
||||||
|
case mdb.NetworkSolana:
|
||||||
|
return symbol != "SOL"
|
||||||
|
case mdb.NetworkTon:
|
||||||
|
return symbol != TonNativeSymbol
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
+162
-26
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -26,6 +27,14 @@ import (
|
|||||||
// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
|
// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
|
||||||
var gProcessedSignatures sync.Map // sig -> unix timestamp
|
var gProcessedSignatures sync.Map // sig -> unix timestamp
|
||||||
|
|
||||||
|
var processSolanaOrder = OrderProcessing
|
||||||
|
|
||||||
|
var (
|
||||||
|
solRPCRetryCount = 5
|
||||||
|
solRPCRetryWait = 2 * time.Second
|
||||||
|
solRPCRetryMaxWait = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type TransferInfo struct {
|
type TransferInfo struct {
|
||||||
Source string // Source address (for SOL) or source ATA (for SPL tokens)
|
Source string // Source address (for SOL) or source ATA (for SPL tokens)
|
||||||
Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
|
Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
|
||||||
@@ -150,6 +159,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
|||||||
// Process each transaction signature
|
// Process each transaction signature
|
||||||
for sigIdx, txSig := range result {
|
for sigIdx, txSig := range result {
|
||||||
sig := txSig.Signature
|
sig := txSig.Signature
|
||||||
|
retrySignature := false
|
||||||
|
|
||||||
// Skip failed transactions
|
// Skip failed transactions
|
||||||
if txSig.Err != nil {
|
if txSig.Err != nil {
|
||||||
@@ -213,6 +223,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
|||||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount)
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err)
|
log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err)
|
||||||
|
retrySignature = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if tradeID == "" {
|
if tradeID == "" {
|
||||||
@@ -226,6 +237,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
|||||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err)
|
log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err)
|
||||||
|
retrySignature = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d",
|
log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d",
|
||||||
@@ -252,13 +264,14 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
|||||||
}
|
}
|
||||||
log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f",
|
log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f",
|
||||||
address, tradeID, sig, token, amount)
|
address, tradeID, sig, token, amount)
|
||||||
err = OrderProcessing(req)
|
err = processSolanaOrder(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||||
log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err)
|
log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err)
|
log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err)
|
||||||
|
retrySignature = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +281,11 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
|||||||
log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig)
|
log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if retrySignature {
|
||||||
|
log.Sugar.Debugf("[SOL][%s] sig=%s not marked processed because retryable processing failed", address, sig)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// 标记已处理
|
// 标记已处理
|
||||||
gProcessedSignatures.Store(sig, time.Now().Unix())
|
gProcessedSignatures.Store(sig, time.Now().Unix())
|
||||||
}
|
}
|
||||||
@@ -282,26 +300,82 @@ type solSignatureResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func resolveSolanaRpcURL() (string, error) {
|
func resolveSolanaRpcURL() (string, error) {
|
||||||
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
node, err := resolveSolanaRpcNode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if node == nil || node.ID == 0 {
|
|
||||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
|
||||||
}
|
|
||||||
rpcURL := strings.TrimSpace(node.Url)
|
rpcURL := strings.TrimSpace(node.Url)
|
||||||
if rpcURL == "" {
|
|
||||||
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
|
||||||
}
|
|
||||||
return rpcURL, nil
|
return rpcURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func resolveSolanaRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
|
||||||
|
node, err := data.SelectGeneralRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp, excludeIDs...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if node == nil || node.ID == 0 {
|
||||||
|
return nil, fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimSpace(node.Url)
|
||||||
|
if rpcURL == "" {
|
||||||
|
return nil, fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||||
|
}
|
||||||
|
node.Url = rpcURL
|
||||||
|
return node, nil
|
||||||
|
}
|
||||||
|
|
||||||
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
||||||
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
func SolRetryClient(method string, params []interface{}) (body []byte, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkSolana)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkSolana)
|
||||||
|
}()
|
||||||
|
|
||||||
|
tried := make([]uint64, 0, 3)
|
||||||
|
var lastErr error
|
||||||
|
for attempts := 0; attempts < 3; attempts++ {
|
||||||
|
node, err := resolveSolanaRpcNode(tried...)
|
||||||
|
if err != nil {
|
||||||
|
if lastErr != nil {
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(tried) > 0 {
|
||||||
|
log.Sugar.Warnf("[SOL] trying alternate RPC node method=%s node=%s", method, data.RpcNodeLogLabel(*node))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := solRetryClientWithURL(node.Url, method, params)
|
||||||
|
if err == nil {
|
||||||
|
data.RecordRpcNodeSuccess(node.ID)
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
lastErr = err
|
||||||
|
failures, cooling := data.RecordRpcNodeFailure(node.ID)
|
||||||
|
nodeLabel := data.RpcNodeLogLabel(*node)
|
||||||
|
if !cooling {
|
||||||
|
log.Sugar.Warnf("[SOL] RPC node failed method=%s node=%s failures=%d/%d", method, nodeLabel, failures, data.RpcFailoverThreshold)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Sugar.Warnf("[SOL] RPC node reached fail threshold method=%s node=%s, trying another node", method, nodeLabel)
|
||||||
|
tried = append(tried, node.ID)
|
||||||
|
}
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func solRetryClientWithURL(rpcUrl string, method string, params []interface{}) ([]byte, error) {
|
||||||
|
return solRetryClientWithURLHeaders(rpcUrl, method, params, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func solRetryClientWithURLHeaders(rpcUrl string, method string, params []interface{}, headers http.Header) ([]byte, error) {
|
||||||
client := resty.New()
|
client := resty.New()
|
||||||
client.SetRetryCount(5)
|
client.SetRetryCount(solRPCRetryCount)
|
||||||
client.SetRetryWaitTime(2 * time.Second)
|
client.SetRetryWaitTime(solRPCRetryWait)
|
||||||
client.SetRetryMaxWaitTime(10 * time.Second)
|
client.SetRetryMaxWaitTime(solRPCRetryMaxWait)
|
||||||
client.AddRetryCondition(func(r *resty.Response, err error) bool {
|
client.AddRetryCondition(func(r *resty.Response, err error) bool {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true
|
return true
|
||||||
@@ -312,25 +386,35 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
rpcUrl, err := resolveSolanaRpcURL()
|
req := client.R().SetHeader("Content-Type", "application/json")
|
||||||
if err != nil {
|
for key, values := range headers {
|
||||||
return nil, err
|
for _, value := range values {
|
||||||
|
req.SetHeader(key, value)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := client.R().
|
resp, err := req.SetBody(map[string]interface{}{
|
||||||
SetHeader("Content-Type", "application/json").
|
"jsonrpc": "2.0",
|
||||||
SetBody(map[string]interface{}{
|
"id": 1,
|
||||||
"jsonrpc": "2.0",
|
"method": method,
|
||||||
"id": 1,
|
"params": params,
|
||||||
"method": method,
|
}).
|
||||||
"params": params,
|
|
||||||
}).
|
|
||||||
Post(rpcUrl)
|
Post(rpcUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if resp.StatusCode() >= http.StatusBadRequest {
|
||||||
|
return nil, fmt.Errorf("solana rpc HTTP %d", resp.StatusCode())
|
||||||
|
}
|
||||||
|
|
||||||
respBody := resp.Body()
|
respBody := resp.Body()
|
||||||
|
if errValue := gjson.GetBytes(respBody, "error"); errValue.Exists() && errValue.Type != gjson.Null {
|
||||||
|
code := errValue.Get("code").String()
|
||||||
|
if code == "" {
|
||||||
|
code = "unknown"
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("solana rpc error code=%s", code)
|
||||||
|
}
|
||||||
|
|
||||||
return respBody, nil
|
return respBody, nil
|
||||||
}
|
}
|
||||||
@@ -367,6 +451,32 @@ func SolGetSignaturesForAddress(address string, limit int, untilSig string, befo
|
|||||||
return bodyData, nil
|
return bodyData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SolGetBlockHeight() (height int64, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkSolana)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkSolana)
|
||||||
|
}()
|
||||||
|
|
||||||
|
rpcURL, err := resolveSolanaRpcURL()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
bodyData, err := solRetryClientWithURL(rpcURL, "getBlockHeight", []interface{}{
|
||||||
|
map[string]interface{}{"commitment": "finalized"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
height = gjson.GetBytes(bodyData, "result").Int()
|
||||||
|
if height <= 0 {
|
||||||
|
return 0, fmt.Errorf("unexpected getBlockHeight response: %s", string(bodyData))
|
||||||
|
}
|
||||||
|
return height, nil
|
||||||
|
}
|
||||||
|
|
||||||
func SolGetTransaction(sig string) ([]byte, error) {
|
func SolGetTransaction(sig string) ([]byte, error) {
|
||||||
txData, err := SolRetryClient("getTransaction", []interface{}{
|
txData, err := SolRetryClient("getTransaction", []interface{}{
|
||||||
sig,
|
sig,
|
||||||
@@ -390,6 +500,33 @@ func SolGetTransaction(sig string) ([]byte, error) {
|
|||||||
return txData, nil
|
return txData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func solGetTransactionWithURL(rpcURL string, sig string) ([]byte, error) {
|
||||||
|
return solGetTransactionWithURLHeaders(rpcURL, sig, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func solGetTransactionWithURLHeaders(rpcURL string, sig string, headers http.Header) ([]byte, error) {
|
||||||
|
txData, err := solRetryClientWithURLHeaders(rpcURL, "getTransaction", []interface{}{
|
||||||
|
sig,
|
||||||
|
map[string]interface{}{
|
||||||
|
"encoding": "jsonParsed",
|
||||||
|
"commitment": "confirmed",
|
||||||
|
"maxSupportedTransactionVersion": 0, // suport
|
||||||
|
},
|
||||||
|
}, headers)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("SolRetryClient failed: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
errField := gjson.GetBytes(txData, "result.meta.err")
|
||||||
|
if errField.Exists() && errField.Type != gjson.Null {
|
||||||
|
log.Sugar.Warnf("Transaction failed: %v", errField.String())
|
||||||
|
return nil, fmt.Errorf("transaction failed: %s", errField.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return txData, nil
|
||||||
|
}
|
||||||
|
|
||||||
// isTransferToAddress 判断转账目标是否为指定钱包地址
|
// isTransferToAddress 判断转账目标是否为指定钱包地址
|
||||||
func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
|
func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
|
||||||
// Native SOL transfer - check destination directly
|
// Native SOL transfer - check destination directly
|
||||||
@@ -471,7 +608,7 @@ const (
|
|||||||
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
|
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留 2 位小数)
|
// ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留到系统支持的最大匹配精度)
|
||||||
func ADJustAmount(amount uint64, decimals int) float64 {
|
func ADJustAmount(amount uint64, decimals int) float64 {
|
||||||
if amount == 0 {
|
if amount == 0 {
|
||||||
return 0
|
return 0
|
||||||
@@ -480,8 +617,7 @@ func ADJustAmount(amount uint64, decimals int) float64 {
|
|||||||
// 10^decimals
|
// 10^decimals
|
||||||
decimalDivisor := decimal.New(1, int32(decimals))
|
decimalDivisor := decimal.New(1, int32(decimals))
|
||||||
adjustedAmount := decimalAmount.Div(decimalDivisor)
|
adjustedAmount := decimalAmount.Div(decimalDivisor)
|
||||||
// Round to 2 decimal places
|
return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), data.MaxAmountPrecision)
|
||||||
return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), 2)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func MatchUsdtAtaAddress(address string, ataTo string) bool {
|
func MatchUsdtAtaAddress(address string, ataTo string) bool {
|
||||||
|
|||||||
@@ -2,12 +2,19 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,6 +28,7 @@ func setupSolanaRPCNode(t *testing.T, url string) func() {
|
|||||||
Type: mdb.RpcNodeTypeHttp,
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
Weight: 1,
|
Weight: 1,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
|
Purpose: mdb.RpcNodePurposeGeneral,
|
||||||
Status: mdb.RpcNodeStatusOk,
|
Status: mdb.RpcNodeStatusOk,
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||||
@@ -52,6 +60,443 @@ func TestResolveSolanaRpcURLWithRow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveSolanaRpcURLIgnoresManualVerifyOnly(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkSolana,
|
||||||
|
Url: "https://paid-solana.example.com",
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Weight: 100,
|
||||||
|
Enabled: true,
|
||||||
|
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed manual rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got, err := resolveSolanaRpcURL(); err == nil {
|
||||||
|
t.Fatalf("resolveSolanaRpcURL() = %q, nil; want error", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveSolanaRpcURLUsesGeneralWhenManualVerifyExists(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkSolana, Url: "https://paid-solana.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkSolana, Url: " https://general-solana.example.com ", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := resolveSolanaRpcURL()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveSolanaRpcURL(): %v", err)
|
||||||
|
}
|
||||||
|
if got != "https://general-solana.example.com" {
|
||||||
|
t.Fatalf("resolveSolanaRpcURL() = %q, want general node", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolGetBlockHeightDoesNotAffectRpcNodeFailover(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
data.ResetRpcFailoverForTest()
|
||||||
|
data.ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||||
|
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
oldRetryCount := solRPCRetryCount
|
||||||
|
solRPCRetryCount = 0
|
||||||
|
t.Cleanup(func() {
|
||||||
|
solRPCRetryCount = oldRetryCount
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "temporary", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
node := &mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkSolana,
|
||||||
|
Url: server.URL,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Weight: 1,
|
||||||
|
Enabled: true,
|
||||||
|
Purpose: mdb.RpcNodePurposeGeneral,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("seed solana rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < data.RpcFailoverThreshold+1; i++ {
|
||||||
|
if _, err := SolGetBlockHeight(); err == nil {
|
||||||
|
t.Fatalf("SolGetBlockHeight attempt %d unexpectedly succeeded", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.IsRpcNodeCoolingDown(node.ID) {
|
||||||
|
t.Fatal("SolGetBlockHeight failures should not affect rpc_node failover cooldown")
|
||||||
|
}
|
||||||
|
stats := data.SnapshotRpcRuntimeStats()
|
||||||
|
if got := stats[mdb.NetworkSolana].FailureCount; got != int64(data.RpcFailoverThreshold+1) {
|
||||||
|
t.Fatalf("solana runtime failure_count = %d, want %d", got, data.RpcFailoverThreshold+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolRetryClientSwitchesAfterFailureThreshold(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
data.ResetRpcFailoverForTest()
|
||||||
|
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||||
|
oldRetryCount := solRPCRetryCount
|
||||||
|
oldRetryWait := solRPCRetryWait
|
||||||
|
oldRetryMaxWait := solRPCRetryMaxWait
|
||||||
|
solRPCRetryCount = 0
|
||||||
|
solRPCRetryWait = time.Millisecond
|
||||||
|
solRPCRetryMaxWait = time.Millisecond
|
||||||
|
t.Cleanup(func() {
|
||||||
|
solRPCRetryCount = oldRetryCount
|
||||||
|
solRPCRetryWait = oldRetryWait
|
||||||
|
solRPCRetryMaxWait = oldRetryMaxWait
|
||||||
|
})
|
||||||
|
|
||||||
|
var primaryCalls int
|
||||||
|
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
primaryCalls++
|
||||||
|
http.Error(w, "temporary", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer primary.Close()
|
||||||
|
|
||||||
|
var backupCalls int
|
||||||
|
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
backupCalls++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": "ok",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer backup.Close()
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkSolana, Url: primary.URL, Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkSolana, Url: backup.URL, Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < data.RpcFailoverThreshold-1; i++ {
|
||||||
|
if _, err := SolRetryClient("getHealth", nil); err == nil {
|
||||||
|
t.Fatalf("SolRetryClient attempt %d unexpectedly succeeded", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if backupCalls != 0 {
|
||||||
|
t.Fatalf("backup calls before threshold = %d, want 0", backupCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := SolRetryClient("getHealth", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SolRetryClient after threshold: %v", err)
|
||||||
|
}
|
||||||
|
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||||
|
t.Fatalf("response body = %s, want result ok", string(body))
|
||||||
|
}
|
||||||
|
if backupCalls == 0 {
|
||||||
|
t.Fatal("backup RPC was not called after threshold")
|
||||||
|
}
|
||||||
|
if primaryCalls == 0 {
|
||||||
|
t.Fatal("primary RPC was not called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolRetryClientCountsJSONRPCErrorAsNodeFailure(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
data.ResetRpcFailoverForTest()
|
||||||
|
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||||
|
oldRetryCount := solRPCRetryCount
|
||||||
|
oldRetryWait := solRPCRetryWait
|
||||||
|
oldRetryMaxWait := solRPCRetryMaxWait
|
||||||
|
solRPCRetryCount = 0
|
||||||
|
solRPCRetryWait = time.Millisecond
|
||||||
|
solRPCRetryMaxWait = time.Millisecond
|
||||||
|
t.Cleanup(func() {
|
||||||
|
solRPCRetryCount = oldRetryCount
|
||||||
|
solRPCRetryWait = oldRetryWait
|
||||||
|
solRPCRetryMaxWait = oldRetryMaxWait
|
||||||
|
})
|
||||||
|
|
||||||
|
var primaryCalls int
|
||||||
|
primary := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
primaryCalls++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"code": -32005,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer primary.Close()
|
||||||
|
|
||||||
|
var backupCalls int
|
||||||
|
backup := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
backupCalls++
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": "ok",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer backup.Close()
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkSolana, Url: primary.URL, Type: mdb.RpcNodeTypeHttp, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkSolana, Url: backup.URL, Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < data.RpcFailoverThreshold-1; i++ {
|
||||||
|
if _, err := SolRetryClient("getHealth", nil); err == nil {
|
||||||
|
t.Fatalf("SolRetryClient attempt %d unexpectedly succeeded", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, err := SolRetryClient("getHealth", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SolRetryClient after JSON-RPC error threshold: %v", err)
|
||||||
|
}
|
||||||
|
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||||
|
t.Fatalf("response body = %s, want result ok", string(body))
|
||||||
|
}
|
||||||
|
if primaryCalls < data.RpcFailoverThreshold {
|
||||||
|
t.Fatalf("primary calls = %d, want at least threshold", primaryCalls)
|
||||||
|
}
|
||||||
|
if backupCalls == 0 {
|
||||||
|
t.Fatal("backup RPC was not called after JSON-RPC error threshold")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolRetryClientWithURLHeadersForwardsCustomHeaders(t *testing.T) {
|
||||||
|
oldRetryCount := solRPCRetryCount
|
||||||
|
oldRetryWait := solRPCRetryWait
|
||||||
|
oldRetryMaxWait := solRPCRetryMaxWait
|
||||||
|
solRPCRetryCount = 0
|
||||||
|
solRPCRetryWait = time.Millisecond
|
||||||
|
solRPCRetryMaxWait = time.Millisecond
|
||||||
|
t.Cleanup(func() {
|
||||||
|
solRPCRetryCount = oldRetryCount
|
||||||
|
solRPCRetryWait = oldRetryWait
|
||||||
|
solRPCRetryMaxWait = oldRetryMaxWait
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("X-Test-Header"); got != "test-value" {
|
||||||
|
t.Errorf("X-Test-Header = %q, want test-value", got)
|
||||||
|
http.Error(w, "bad custom header", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": "ok",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
headers := http.Header{}
|
||||||
|
headers.Set("X-Test-Header", "test-value")
|
||||||
|
body, err := solRetryClientWithURLHeaders(server.URL, "getHealth", nil, headers)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("solRetryClientWithURLHeaders(): %v", err)
|
||||||
|
}
|
||||||
|
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||||
|
t.Fatalf("response body = %s, want result ok", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolRetryClientWithURLDoesNotForwardUserIPByDefault(t *testing.T) {
|
||||||
|
oldRetryCount := solRPCRetryCount
|
||||||
|
oldRetryWait := solRPCRetryWait
|
||||||
|
oldRetryMaxWait := solRPCRetryMaxWait
|
||||||
|
solRPCRetryCount = 0
|
||||||
|
solRPCRetryWait = time.Millisecond
|
||||||
|
solRPCRetryMaxWait = time.Millisecond
|
||||||
|
t.Cleanup(func() {
|
||||||
|
solRPCRetryCount = oldRetryCount
|
||||||
|
solRPCRetryWait = oldRetryWait
|
||||||
|
solRPCRetryMaxWait = oldRetryMaxWait
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if got := r.Header.Get("X-USER-IP"); got != "" {
|
||||||
|
t.Errorf("X-USER-IP header = %q, want empty", got)
|
||||||
|
http.Error(w, "unexpected user ip header", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": "ok",
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
body, err := solRetryClientWithURL(server.URL, "getHealth", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("solRetryClientWithURL(): %v", err)
|
||||||
|
}
|
||||||
|
if gjson.GetBytes(body, "result").String() != "ok" {
|
||||||
|
t.Fatalf("response body = %s, want result ok", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSolCallBackDoesNotCacheSignatureAfterRetryableOrderProcessingError(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
clearProcessedSignatures()
|
||||||
|
t.Cleanup(clearProcessedSignatures)
|
||||||
|
|
||||||
|
const (
|
||||||
|
address = "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||||
|
sig = "retryable-sol-signature"
|
||||||
|
tradeID = "sol-retryable-order-001"
|
||||||
|
amount = 1.23
|
||||||
|
)
|
||||||
|
blockTime := time.Now().Add(time.Minute).Unix()
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var rpcReq struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&rpcReq); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch rpcReq.Method {
|
||||||
|
case "getSignaturesForAddress":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"signature": sig,
|
||||||
|
"slot": 1,
|
||||||
|
"err": nil,
|
||||||
|
"blockTime": blockTime,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "getTransaction":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": map[string]interface{}{
|
||||||
|
"blockTime": blockTime,
|
||||||
|
"meta": map[string]interface{}{"err": nil},
|
||||||
|
"transaction": map[string]interface{}{
|
||||||
|
"message": map[string]interface{}{
|
||||||
|
"instructions": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"programId": SystemProgramID,
|
||||||
|
"parsed": map[string]interface{}{
|
||||||
|
"type": "transfer",
|
||||||
|
"info": map[string]interface{}{
|
||||||
|
"source": "source-wallet",
|
||||||
|
"destination": address,
|
||||||
|
"lamports": 1_230_000_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.Error(w, "unexpected method "+rpcReq.Method, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkSolana,
|
||||||
|
Url: server.URL,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Weight: 1,
|
||||||
|
Enabled: true,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed solana rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&mdb.ChainToken{
|
||||||
|
Network: mdb.NetworkSolana,
|
||||||
|
Symbol: "SOL",
|
||||||
|
ContractAddress: "",
|
||||||
|
Decimals: SOL_Decimals,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed SOL chain token: %v", err)
|
||||||
|
}
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: tradeID,
|
||||||
|
OrderId: "merchant-sol-retryable-001",
|
||||||
|
Amount: 100,
|
||||||
|
Currency: "CNY",
|
||||||
|
ActualAmount: amount,
|
||||||
|
ReceiveAddress: address,
|
||||||
|
Token: "SOL",
|
||||||
|
Network: mdb.NetworkSolana,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("seed order: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.LockTransaction(mdb.NetworkSolana, address, "SOL", tradeID, amount, time.Hour); err != nil {
|
||||||
|
t.Fatalf("lock transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
oldProcessSolanaOrder := processSolanaOrder
|
||||||
|
calls := 0
|
||||||
|
processSolanaOrder = func(req *request.OrderProcessingRequest) error {
|
||||||
|
calls++
|
||||||
|
if req.TradeId != tradeID {
|
||||||
|
t.Fatalf("order processing trade_id = %q, want %q", req.TradeId, tradeID)
|
||||||
|
}
|
||||||
|
return errors.New("temporary database error")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
processSolanaOrder = oldProcessSolanaOrder
|
||||||
|
})
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
SolCallBack(address, &wg)
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("order processing calls = %d, want 1", calls)
|
||||||
|
}
|
||||||
|
if _, ok := gProcessedSignatures.Load(sig); ok {
|
||||||
|
t.Fatalf("signature %s was cached after retryable order processing error", sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSolClientHealthy(t *testing.T) {
|
func TestSolClientHealthy(t *testing.T) {
|
||||||
requireSolanaIntegration(t)
|
requireSolanaIntegration(t)
|
||||||
|
|
||||||
@@ -78,6 +523,13 @@ func TestSolClientHealthy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clearProcessedSignatures() {
|
||||||
|
gProcessedSignatures.Range(func(key, value interface{}) bool {
|
||||||
|
gProcessedSignatures.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestSolClientGetSignaturesForAddress(t *testing.T) {
|
func TestSolClientGetSignaturesForAddress(t *testing.T) {
|
||||||
requireSolanaIntegration(t)
|
requireSolanaIntegration(t)
|
||||||
|
|
||||||
@@ -236,7 +688,7 @@ func TestAdjustAmount(t *testing.T) {
|
|||||||
name: "USDT amount (6 decimals)",
|
name: "USDT amount (6 decimals)",
|
||||||
amount: 123456789,
|
amount: 123456789,
|
||||||
decimals: 6,
|
decimals: 6,
|
||||||
want: 123.46,
|
want: 123.456789,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "USDC amount (6 decimals)",
|
name: "USDC amount (6 decimals)",
|
||||||
@@ -260,7 +712,7 @@ func TestAdjustAmount(t *testing.T) {
|
|||||||
name: "Small amount",
|
name: "Small amount",
|
||||||
amount: 1,
|
amount: 1,
|
||||||
decimals: 6,
|
decimals: 6,
|
||||||
want: 0.0,
|
want: 0.000001,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+169
-310
@@ -1,337 +1,186 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"net/http"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
tron "github.com/GMWalletApp/epusdt/crypto"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/GMWalletApp/epusdt/notify"
|
"github.com/GMWalletApp/epusdt/notify"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/GMWalletApp/epusdt/util/math"
|
"github.com/GMWalletApp/epusdt/util/math"
|
||||||
"github.com/dromara/carbon/v2"
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
"github.com/gookit/goutil/stdutil"
|
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tidwall/gjson"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
|
|
||||||
// It reads the first healthy (or any enabled) row from the rpc_nodes table.
|
|
||||||
func resolveTronNode() (string, string, error) {
|
func resolveTronNode() (string, string, error) {
|
||||||
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
node, err := ResolveTronRpcNode()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
if node == nil || node.ID == 0 {
|
|
||||||
return "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
|
||||||
}
|
|
||||||
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
|
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
|
||||||
if rpcURL == "" {
|
|
||||||
return "", "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
|
||||||
}
|
|
||||||
return rpcURL, node.ApiKey, nil
|
return rpcURL, node.ApiKey, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Trc20CallBack(address string, wg *sync.WaitGroup) {
|
func ResolveTronRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
|
||||||
defer wg.Done()
|
node, err := data.SelectGeneralRpcNode(mdb.NetworkTron, 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.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimRight(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 ResolveTronNode() (string, string, error) {
|
||||||
|
return resolveTronNode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TryProcessTronTRC20Transfer(token mdb.ChainToken, toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
|
||||||
|
tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol))
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := recover(); err != nil {
|
if err := recover(); err != nil {
|
||||||
log.Sugar.Error(err)
|
log.Sugar.Errorf("[TRC20-%s][%s] TryProcessTronTRC20Transfer panic: %v", tokenSym, toAddr, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var innerWg sync.WaitGroup
|
addr := strings.TrimSpace(toAddr)
|
||||||
innerWg.Add(2)
|
if tokenSym == "" || addr == "" || rawValue == nil || rawValue.Sign() <= 0 {
|
||||||
go checkTrxTransfers(address, &innerWg)
|
return
|
||||||
go checkTrc20Transfers(address, &innerWg)
|
}
|
||||||
innerWg.Wait()
|
decimals := token.Decimals
|
||||||
|
if decimals < 0 {
|
||||||
|
decimals = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||||
|
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(decimals))).InexactFloat64(), data.MaxAmountPrecision)
|
||||||
|
if amount <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if token.MinAmount > 0 && amount < token.MinAmount {
|
||||||
|
log.Sugar.Debugf("[TRC20-%s][%s] skip below min amount hash=%s amount=%.2f min=%.2f", tokenSym, addr, txHash, amount, token.MinAmount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, tokenSym, amount)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TRC20-%s][%s] lock lookup: %v", tokenSym, addr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tradeID == "" {
|
||||||
|
log.Sugar.Debugf("[TRC20-%s][%s] skip unmatched tx hash=%s amount=%.2f", tokenSym, addr, txHash, amount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TRC20-%s][%s] load order: %v", tokenSym, addr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTron {
|
||||||
|
log.Sugar.Warnf("[TRC20-%s][%s] skip trade_id=%s network=%q", tokenSym, addr, tradeID, order.Network)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym {
|
||||||
|
log.Sugar.Warnf("[TRC20-%s][%s] skip trade_id=%s token mismatch order=%s", tokenSym, addr, tradeID, order.Token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
|
||||||
|
log.Sugar.Warnf("[TRC20-%s][%s] skip tx %s because block time %d is before order create time %d", tokenSym, addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: addr,
|
||||||
|
Token: tokenSym,
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
TradeId: tradeID,
|
||||||
|
Amount: amount,
|
||||||
|
BlockTransactionId: txHash,
|
||||||
|
}
|
||||||
|
err = OrderProcessing(req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||||
|
log.Sugar.Infof("[TRC20-%s][%s] skip resolved transfer trade_id=%s hash=%s err=%v", tokenSym, addr, tradeID, txHash, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Errorf("[TRC20-%s][%s] OrderProcessing trade_id=%s hash=%s: %v", tokenSym, addr, tradeID, txHash, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPaymentNotification(order)
|
||||||
|
log.Sugar.Infof("[TRC20-%s][%s] payment processed trade_id=%s hash=%s", tokenSym, addr, tradeID, txHash)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
func TryProcessTronTRXTransfer(toAddr string, rawSun int64, txHash string, blockTsMs int64) {
|
||||||
defer wg.Done()
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
log.Sugar.Errorf("[TRX][%s] TryProcessTronTRXTransfer panic: %v", toAddr, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// Native TRX is gated by a chain_tokens row with empty
|
addr := strings.TrimSpace(toAddr)
|
||||||
// contract_address and symbol=TRX. Admin can disable this row to
|
if addr == "" || rawSun <= 0 {
|
||||||
// stop scanning native transfers without touching the chain toggle.
|
return
|
||||||
trxCfg, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, "TRX")
|
}
|
||||||
|
|
||||||
|
decimalQuant := decimal.NewFromInt(rawSun)
|
||||||
|
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), data.MaxAmountPrecision)
|
||||||
|
if amount <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "TRX", amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[TRX][%s] load chain_tokens err=%v", address, err)
|
log.Sugar.Warnf("[TRX][%s] lock lookup: %v", addr, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if trxCfg == nil || trxCfg.ID == 0 {
|
if tradeID == "" {
|
||||||
log.Sugar.Debugf("[TRX][%s] native TRX disabled in chain_tokens, skipping", address)
|
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
trxDecimals := trxCfg.Decimals
|
|
||||||
if trxDecimals <= 0 {
|
|
||||||
trxDecimals = 6
|
|
||||||
}
|
|
||||||
|
|
||||||
client := http_client.GetHttpClient()
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
|
||||||
endTime := carbon.Now().TimestampMilli()
|
|
||||||
tronBaseURL, tronAPIKey, err := resolveTronNode()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[TRX][%s] resolve rpc_nodes err=%v", address, err)
|
log.Sugar.Warnf("[TRX][%s] load order: %v", addr, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
|
||||||
|
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, address)
|
|
||||||
|
|
||||||
resp, err := client.R().SetQueryParams(map[string]string{
|
req := &request.OrderProcessingRequest{
|
||||||
"order_by": "block_timestamp,desc",
|
ReceiveAddress: addr,
|
||||||
"limit": "100",
|
Token: "TRX",
|
||||||
"only_to": "true",
|
Network: mdb.NetworkTron,
|
||||||
"min_timestamp": stdutil.ToString(startTime),
|
TradeId: tradeID,
|
||||||
"max_timestamp": stdutil.ToString(endTime),
|
Amount: amount,
|
||||||
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
|
BlockTransactionId: txHash,
|
||||||
|
}
|
||||||
|
err = OrderProcessing(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[TRX][%s] HTTP request failed: %v", address, err)
|
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||||
return
|
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
|
||||||
}
|
return
|
||||||
if resp.StatusCode() != http.StatusOK {
|
}
|
||||||
log.Sugar.Errorf("[TRX][%s] API returned status %d", address, resp.StatusCode())
|
log.Sugar.Errorf("[TRX][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
sendPaymentNotification(order)
|
||||||
if !success {
|
log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
|
||||||
log.Sugar.Errorf("[TRX][%s] API response indicates failure", address)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transfers := gjson.GetBytes(resp.Body(), "data").Array()
|
|
||||||
if len(transfers) == 0 {
|
|
||||||
log.Sugar.Debugf("[TRX][%s] no transfer records found", address)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
|
|
||||||
|
|
||||||
for i, transfer := range transfers {
|
|
||||||
if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
|
|
||||||
toBytes, err := hex.DecodeString(toAddressHex)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if tron.EncodeCheck(toBytes) != address {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String()
|
|
||||||
decimalQuant, err := decimal.NewFromString(rawAmount)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(trxDecimals))).InexactFloat64(), 2)
|
|
||||||
if trxCfg.MinAmount > 0 && amount < trxCfg.MinAmount {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if amount <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
txID := transfer.Get("txID").String()
|
|
||||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)), amount)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[TRX][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if tradeID == "" {
|
|
||||||
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
|
|
||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[TRX][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
blockTimestamp := transfer.Get("block_timestamp").Int()
|
|
||||||
createTime := order.CreatedAt.TimestampMilli()
|
|
||||||
if blockTimestamp < createTime {
|
|
||||||
log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &request.OrderProcessingRequest{
|
|
||||||
ReceiveAddress: address,
|
|
||||||
Token: strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)),
|
|
||||||
Network: mdb.NetworkTron,
|
|
||||||
TradeId: tradeID,
|
|
||||||
Amount: amount,
|
|
||||||
BlockTransactionId: txID,
|
|
||||||
}
|
|
||||||
err = OrderProcessing(req)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
|
||||||
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Sugar.Errorf("[TRX][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sendPaymentNotification(order)
|
|
||||||
log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
|
||||||
defer wg.Done()
|
|
||||||
|
|
||||||
// Build contract -> token map for the TRON network. If nothing is
|
|
||||||
// configured, skip — preserves the previous behavior of only watching
|
|
||||||
// admin-approved tokens.
|
|
||||||
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTron)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[TRC20][%s] load chain_tokens err=%v", address, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(tokens) == 0 {
|
|
||||||
log.Sugar.Debugf("[TRC20][%s] no enabled chain_tokens, skipping", address)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
contractTokens := make(map[string]*mdb.ChainToken, len(tokens))
|
|
||||||
for i := range tokens {
|
|
||||||
c := strings.TrimSpace(tokens[i].ContractAddress)
|
|
||||||
if c == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
contractTokens[c] = &tokens[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
client := http_client.GetHttpClient()
|
|
||||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
|
||||||
endTime := carbon.Now().TimestampMilli()
|
|
||||||
tronBaseURL, tronAPIKey, err := resolveTronNode()
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[TRC20][%s] resolve rpc_nodes err=%v", address, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, address)
|
|
||||||
|
|
||||||
resp, err := client.R().SetQueryParams(map[string]string{
|
|
||||||
"order_by": "block_timestamp,desc",
|
|
||||||
"limit": "100",
|
|
||||||
"only_to": "true",
|
|
||||||
"min_timestamp": stdutil.ToString(startTime),
|
|
||||||
"max_timestamp": stdutil.ToString(endTime),
|
|
||||||
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[TRC20][%s] HTTP request failed: %v", address, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if resp.StatusCode() != http.StatusOK {
|
|
||||||
log.Sugar.Errorf("[TRC20][%s] API returned status %d", address, resp.StatusCode())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
|
||||||
if !success {
|
|
||||||
log.Sugar.Errorf("[TRC20][%s] API response indicates failure", address)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
transfers := gjson.GetBytes(resp.Body(), "data").Array()
|
|
||||||
if len(transfers) == 0 {
|
|
||||||
log.Sugar.Debugf("[TRC20][%s] no transfer records found", address)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
|
|
||||||
|
|
||||||
for i, transfer := range transfers {
|
|
||||||
contractAddr := transfer.Get("token_info.address").String()
|
|
||||||
cfg, ok := contractTokens[contractAddr]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if transfer.Get("to").String() != address {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tokenSym := strings.ToUpper(strings.TrimSpace(cfg.Symbol))
|
|
||||||
|
|
||||||
valueStr := transfer.Get("value").String()
|
|
||||||
decimalQuant, err := decimal.NewFromString(valueStr)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[TRC20][%s] parse value failed on tx #%d: %v", address, i, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tokenDecimals := transfer.Get("token_info.decimals").Int()
|
|
||||||
if tokenDecimals <= 0 {
|
|
||||||
tokenDecimals = int64(cfg.Decimals)
|
|
||||||
}
|
|
||||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
|
|
||||||
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if amount <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
txID := transfer.Get("transaction_id").String()
|
|
||||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, tokenSym, amount)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[TRC20][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if tradeID == "" {
|
|
||||||
log.Sugar.Debugf("[TRC20][%s] skip unmatched %s tx hash=%s amount=%.2f", address, tokenSym, txID, amount)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Sugar.Infof("[TRC20][%s] matched %s trade_id=%s hash=%s amount=%.2f", address, tokenSym, tradeID, txID, amount)
|
|
||||||
|
|
||||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[TRC20][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
blockTimestamp := transfer.Get("block_timestamp").Int()
|
|
||||||
createTime := order.CreatedAt.TimestampMilli()
|
|
||||||
if blockTimestamp < createTime {
|
|
||||||
log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &request.OrderProcessingRequest{
|
|
||||||
ReceiveAddress: address,
|
|
||||||
Token: tokenSym,
|
|
||||||
Network: mdb.NetworkTron,
|
|
||||||
TradeId: tradeID,
|
|
||||||
Amount: amount,
|
|
||||||
BlockTransactionId: txID,
|
|
||||||
}
|
|
||||||
err = OrderProcessing(req)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
|
||||||
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
log.Sugar.Errorf("[TRC20][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sendPaymentNotification(order)
|
|
||||||
log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func evmChainLogLabel(chainNetwork string) string {
|
func evmChainLogLabel(chainNetwork string) string {
|
||||||
@@ -349,9 +198,6 @@ func evmChainLogLabel(chainNetwork string) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TryProcessEvmERC20Transfer 处理各 EVM 链上代币的 Transfer 入账。
|
|
||||||
// 代币识别、符号和 decimals 全部从 chain_tokens 表动态查询 —
|
|
||||||
// 管理后台新增 token 即可立即生效,无需代码改动。
|
|
||||||
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
|
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := recover(); err != nil {
|
if err := recover(); err != nil {
|
||||||
@@ -360,41 +206,41 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
net := evmChainLogLabel(chainNetwork)
|
net := evmChainLogLabel(chainNetwork)
|
||||||
token, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
|
tokenConfig, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Warnf("[%s-WS] chain_tokens lookup err=%v contract=%s", net, err, contract.Hex())
|
log.Sugar.Warnf("[%s-WS] load chain token contract=%s: %v", net, contract.Hex(), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if token == nil || token.ID == 0 {
|
if tokenConfig == nil || tokenConfig.ID == 0 {
|
||||||
log.Sugar.Debugf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
|
log.Sugar.Warnf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol))
|
tokenSym := strings.ToUpper(strings.TrimSpace(tokenConfig.Symbol))
|
||||||
if tokenSym == "" {
|
if tokenSym == "" {
|
||||||
log.Sugar.Warnf("[%s-WS] chain_token id=%d has empty symbol", net, token.ID)
|
log.Sugar.Warnf("[%s-WS] skip contract %s with empty token symbol", net, contract.Hex())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
decimals := token.Decimals
|
|
||||||
if decimals <= 0 {
|
|
||||||
decimals = 6
|
|
||||||
}
|
|
||||||
|
|
||||||
walletAddr := strings.ToLower(toAddr.Hex())
|
walletAddr := strings.ToLower(toAddr.Hex())
|
||||||
if rawValue == nil || rawValue.Sign() <= 0 {
|
if rawValue == nil || rawValue.Sign() <= 0 {
|
||||||
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
|
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
divisor := decimal.New(1, int32(decimals))
|
decimals := tokenConfig.Decimals
|
||||||
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
if decimals < 0 {
|
||||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
|
decimals = 0
|
||||||
if token.MinAmount > 0 && amount < token.MinAmount {
|
|
||||||
log.Sugar.Debugf("[%s-%s][%s] skip amount %.2f below min_amount %.2f", net, tokenSym, walletAddr, amount, token.MinAmount)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
pow := decimal.New(1, int32(decimals))
|
||||||
|
|
||||||
|
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||||
|
amount := math.MustParsePrecFloat64(decimalQuant.Div(pow).InexactFloat64(), data.MaxAmountPrecision)
|
||||||
if amount <= 0 {
|
if amount <= 0 {
|
||||||
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
|
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if tokenConfig.MinAmount > 0 && amount < tokenConfig.MinAmount {
|
||||||
|
log.Sugar.Debugf("[%s-%s][%s] skip below min amount hash=%s amount=%.2f min=%.2f", net, tokenSym, walletAddr, txHash, amount, tokenConfig.MinAmount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Sugar.Debugf("[%s-%s][%s] processing transfer hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount)
|
log.Sugar.Debugf("[%s-%s][%s] processing transfer hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount)
|
||||||
|
|
||||||
@@ -422,8 +268,7 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
|
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
|
||||||
log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block_time_ms=%d is before order created_ms=%d",
|
log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block time %d is before order create time %d", net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
||||||
net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,11 +295,25 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sendPaymentNotification(order *mdb.Orders) {
|
func sendPaymentNotification(order *mdb.Orders) {
|
||||||
|
if order == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(order.TradeId) != "" {
|
||||||
|
latest, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[notify] reload order failed trade_id=%s err=%v", order.TradeId, err)
|
||||||
|
} else if latest != nil && latest.TradeId != "" {
|
||||||
|
order = latest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
precision := data.GetAmountPrecision()
|
||||||
|
amountFormat := fmt.Sprintf("%%.%df", precision)
|
||||||
msg := fmt.Sprintf(
|
msg := fmt.Sprintf(
|
||||||
"🎉 <b>收款成功通知</b>\n\n"+
|
"🎉 <b>收款成功通知</b>\n\n"+
|
||||||
"💰 <b>金额信息</b>\n"+
|
"💰 <b>金额信息</b>\n"+
|
||||||
"├ 订单金额:<code>%.2f %s</code>\n"+
|
"├ 订单金额:<code>"+amountFormat+" %s</code>\n"+
|
||||||
"└ 实际到账:<code>%.2f %s</code>\n\n"+
|
"└ 实际到账:<code>"+amountFormat+" %s</code>\n\n"+
|
||||||
"📋 <b>订单信息</b>\n"+
|
"📋 <b>订单信息</b>\n"+
|
||||||
"├ 交易号:<code>%s</code>\n"+
|
"├ 交易号:<code>%s</code>\n"+
|
||||||
"├ 订单号:<code>%s</code>\n"+
|
"├ 订单号:<code>%s</code>\n"+
|
||||||
@@ -472,7 +331,7 @@ func sendPaymentNotification(order *mdb.Orders) {
|
|||||||
networkDisplay(order.Network),
|
networkDisplay(order.Network),
|
||||||
order.ReceiveAddress,
|
order.ReceiveAddress,
|
||||||
order.CreatedAt.ToDateTimeString(),
|
order.CreatedAt.ToDateTimeString(),
|
||||||
carbon.Now().ToDateTimeString(),
|
order.UpdatedAt.ToDateTimeString(),
|
||||||
)
|
)
|
||||||
notify.Dispatch(mdb.NotifyEventPaySuccess, msg)
|
notify.Dispatch(mdb.NotifyEventPaySuccess, msg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/notify"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
func upsertTaskServiceTestChainToken(t *testing.T, token mdb.ChainToken) {
|
||||||
|
t.Helper()
|
||||||
|
if err := dao.Mdb.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "network"}, {Name: "symbol"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"contract_address",
|
||||||
|
"decimals",
|
||||||
|
"enabled",
|
||||||
|
"min_amount",
|
||||||
|
}),
|
||||||
|
}).Create(&token).Error; err != nil {
|
||||||
|
t.Fatalf("seed chain token: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendPaymentNotificationUsesLatestOrderUpdatedAt(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
const channelType = "test-pay-success-time"
|
||||||
|
got := make(chan string, 1)
|
||||||
|
notify.RegisterSender(channelType, func(config, text string) error {
|
||||||
|
got <- text
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.NotificationChannel{
|
||||||
|
Type: channelType,
|
||||||
|
Name: "test",
|
||||||
|
Config: "{}",
|
||||||
|
Events: `{"pay_success":true}`,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed notification channel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: "T202604270001",
|
||||||
|
OrderId: "ORD202604270001",
|
||||||
|
Amount: 100,
|
||||||
|
Currency: "cny",
|
||||||
|
ActualAmount: 14.28,
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
ReceiveAddress: "TTestAddress",
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("seed order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = "2026-04-27 09:00:00"
|
||||||
|
const staleUpdatedAt = "2026-04-27 09:01:00"
|
||||||
|
const paidAt = "2026-04-27 10:20:30"
|
||||||
|
if err := dao.Mdb.Exec("UPDATE orders SET created_at = ?, updated_at = ? WHERE trade_id = ?", createdAt, staleUpdatedAt, order.TradeId).Error; err != nil {
|
||||||
|
t.Fatalf("set initial timestamps: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var staleOrderModel mdb.Orders
|
||||||
|
if err := dao.Mdb.Where("trade_id = ?", order.TradeId).Take(&staleOrderModel).Error; err != nil {
|
||||||
|
t.Fatalf("load stale order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := dao.Mdb.Exec("UPDATE orders SET status = ?, updated_at = ? WHERE trade_id = ?", mdb.StatusPaySuccess, paidAt, order.TradeId).Error; err != nil {
|
||||||
|
t.Fatalf("set paid timestamp: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPaymentNotification(&staleOrderModel)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case text := <-got:
|
||||||
|
if !strings.Contains(text, "支付时间:"+paidAt) {
|
||||||
|
t.Fatalf("notification payment time = %q, want %s", text, paidAt)
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "支付时间:"+staleUpdatedAt) {
|
||||||
|
t.Fatalf("notification used stale payment time: %q", text)
|
||||||
|
}
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for notification")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryProcessEvmERC20TransferUsesChainTokenContract(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
const (
|
||||||
|
tradeID = "T202605110001"
|
||||||
|
orderID = "ORD202605110001"
|
||||||
|
tokenSym = "FOO"
|
||||||
|
amount = 12.34
|
||||||
|
)
|
||||||
|
contract := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||||
|
receiveAddress := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.ChainToken{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Symbol: tokenSym,
|
||||||
|
ContractAddress: contract.Hex(),
|
||||||
|
Decimals: 8,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed chain token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: tradeID,
|
||||||
|
OrderId: orderID,
|
||||||
|
Amount: 100,
|
||||||
|
Currency: "CNY",
|
||||||
|
ActualAmount: amount,
|
||||||
|
Token: tokenSym,
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
ReceiveAddress: strings.ToLower(receiveAddress.Hex()),
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("seed order: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.LockTransaction(mdb.NetworkEthereum, order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil {
|
||||||
|
t.Fatalf("lock transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawValue := big.NewInt(1_234_000_000) // 12.34 with 8 decimals
|
||||||
|
TryProcessEvmERC20Transfer(mdb.NetworkEthereum, contract, receiveAddress, rawValue, "0xfoo-hash", time.Now().UnixMilli())
|
||||||
|
|
||||||
|
paid, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load paid order: %v", err)
|
||||||
|
}
|
||||||
|
if paid.Status != mdb.StatusPaySuccess {
|
||||||
|
t.Fatalf("order status = %d, want %d", paid.Status, mdb.StatusPaySuccess)
|
||||||
|
}
|
||||||
|
if paid.BlockTransactionId != "0xfoo-hash" {
|
||||||
|
t.Fatalf("block transaction id = %q, want %q", paid.BlockTransactionId, "0xfoo-hash")
|
||||||
|
}
|
||||||
|
|
||||||
|
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, receiveAddress.Hex(), tokenSym, amount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup released lock: %v", err)
|
||||||
|
}
|
||||||
|
if lockTradeID != "" {
|
||||||
|
t.Fatalf("runtime lock still exists for trade_id=%q", lockTradeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryProcessEvmERC20TransferSkipsTransfersBeforeOrderCreation(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
const (
|
||||||
|
tradeID = "T202605110002"
|
||||||
|
orderID = "ORD202605110002"
|
||||||
|
tokenSym = "USDT"
|
||||||
|
amount = 25.5
|
||||||
|
)
|
||||||
|
contract := common.HexToAddress("0x3333333333333333333333333333333333333333")
|
||||||
|
receiveAddress := common.HexToAddress("0x4444444444444444444444444444444444444444")
|
||||||
|
|
||||||
|
upsertTaskServiceTestChainToken(t, mdb.ChainToken{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Symbol: tokenSym,
|
||||||
|
ContractAddress: contract.Hex(),
|
||||||
|
Decimals: 6,
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: tradeID,
|
||||||
|
OrderId: orderID,
|
||||||
|
Amount: 100,
|
||||||
|
Currency: "CNY",
|
||||||
|
ActualAmount: amount,
|
||||||
|
Token: tokenSym,
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
ReceiveAddress: strings.ToLower(receiveAddress.Hex()),
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("seed order: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.LockTransaction(mdb.NetworkEthereum, order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil {
|
||||||
|
t.Fatalf("lock transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawValue := big.NewInt(25_500_000) // 25.5 with 6 decimals
|
||||||
|
oldBlockTsMs := order.CreatedAt.TimestampMilli() - 1
|
||||||
|
TryProcessEvmERC20Transfer(mdb.NetworkEthereum, contract, receiveAddress, rawValue, "0xold-hash", oldBlockTsMs)
|
||||||
|
|
||||||
|
got, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load order: %v", err)
|
||||||
|
}
|
||||||
|
if got.Status != mdb.StatusWaitPay {
|
||||||
|
t.Fatalf("order status = %d, want %d", got.Status, mdb.StatusWaitPay)
|
||||||
|
}
|
||||||
|
if got.BlockTransactionId != "" {
|
||||||
|
t.Fatalf("block transaction id = %q, want empty", got.BlockTransactionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, receiveAddress.Hex(), tokenSym, amount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup retained lock: %v", err)
|
||||||
|
}
|
||||||
|
if lockTradeID != tradeID {
|
||||||
|
t.Fatalf("runtime lock trade_id = %q, want %q", lockTradeID, tradeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,49 @@ func TestResolveTronNode_WithRow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveTronNodeIgnoresManualVerifyOnly(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Url: "https://paid-tron.example.com",
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
ApiKey: "paid-key",
|
||||||
|
Weight: 100,
|
||||||
|
Enabled: true,
|
||||||
|
Purpose: mdb.RpcNodePurposeManualVerify,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed manual rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotURL, gotKey, err := resolveTronNode(); err == nil {
|
||||||
|
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTronNodeUsesGeneralWhenManualVerifyExists(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://paid-tron.example.com", Type: mdb.RpcNodeTypeHttp, ApiKey: "paid-key", Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkTron, Url: "https://general-tron.example.com", Type: mdb.RpcNodeTypeHttp, ApiKey: "general-key", Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotURL, gotKey, err := resolveTronNode()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveTronNode(): %v", err)
|
||||||
|
}
|
||||||
|
if gotURL != "https://general-tron.example.com" || gotKey != "general-key" {
|
||||||
|
t.Fatalf("resolveTronNode() = (%q, %q), want general node", gotURL, gotKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
|
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
|
||||||
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xssnick/tonutils-go/liteclient"
|
||||||
|
"github.com/xssnick/tonutils-go/ton"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectTonLiteAPI builds a tonutils-go API client from a liteserver global
|
||||||
|
// config URL. Scanner and manual verification use this same connection setup.
|
||||||
|
func ConnectTonLiteAPI(ctx context.Context, configURL string, requestTimeout time.Duration, reconnectLimit int) (ton.APIClientWrapped, func(), error) {
|
||||||
|
configURL = strings.TrimSpace(configURL)
|
||||||
|
if configURL == "" {
|
||||||
|
return nil, func() {}, fmt.Errorf("ton lite config url is empty")
|
||||||
|
}
|
||||||
|
if reconnectLimit <= 0 {
|
||||||
|
reconnectLimit = 3
|
||||||
|
}
|
||||||
|
cfg, err := liteclient.GetConfigFromUrl(ctx, configURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, func() {}, err
|
||||||
|
}
|
||||||
|
pool := liteclient.NewConnectionPool()
|
||||||
|
pool.SetOnDisconnect(pool.DefaultReconnect(3*time.Second, reconnectLimit))
|
||||||
|
if err = pool.AddConnectionsFromConfig(ctx, cfg); err != nil {
|
||||||
|
pool.Stop()
|
||||||
|
return nil, func() {}, err
|
||||||
|
}
|
||||||
|
base := ton.NewAPIClient(pool, ton.ProofCheckPolicyFast)
|
||||||
|
base.SetTrustedBlockFromConfig(cfg)
|
||||||
|
api := base.WithRetry(2).WithTimeout(requestTimeout).WithLSInfoInErrors()
|
||||||
|
return api, pool.Stop, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/math"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
|
"github.com/xssnick/tonutils-go/tlb"
|
||||||
|
"github.com/xssnick/tonutils-go/ton/jetton"
|
||||||
|
"github.com/xssnick/tonutils-go/tvm/cell"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TonNativeSymbol = "TON"
|
||||||
|
TonTransferNotificationOpcode = 0x7362d09c
|
||||||
|
)
|
||||||
|
|
||||||
|
type TonObservedTransfer struct {
|
||||||
|
ReceiveAddress string
|
||||||
|
Token mdb.ChainToken
|
||||||
|
RawAmount *big.Int
|
||||||
|
Amount float64
|
||||||
|
BlockTimeMs int64
|
||||||
|
LT uint64
|
||||||
|
TxHashHex string
|
||||||
|
BlockID string
|
||||||
|
SenderAddress string
|
||||||
|
JettonWallet string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TonCanonicalBlockTransactionID(receiveRaw string, lt uint64, txHashHex string) string {
|
||||||
|
return fmt.Sprintf("ton:%s:%d:%s", strings.ToLower(strings.TrimSpace(receiveRaw)), lt, strings.ToLower(strings.TrimSpace(txHashHex)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseTonInboundTransfer(tx *tlb.Transaction, receive *address.Address, nativeToken *mdb.ChainToken, jettonWalletTokens map[string]mdb.ChainToken) (*TonObservedTransfer, error) {
|
||||||
|
if tx == nil || receive == nil || tx.IO.In == nil || tx.IO.In.MsgType != tlb.MsgTypeInternal {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if isTonTransactionBounced(tx) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
in := tx.IO.In.AsInternal()
|
||||||
|
if in == nil || in.DstAddr == nil || !in.DstAddr.Equals(receive) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
receiveRaw := addressutil.TonRawAddressObjectKey(receive)
|
||||||
|
txHashHex := hex.EncodeToString(tx.Hash)
|
||||||
|
blockID := TonCanonicalBlockTransactionID(receiveRaw, tx.LT, txHashHex)
|
||||||
|
blockTimeMs := int64(tx.Now) * 1000
|
||||||
|
if in.SrcAddr != nil {
|
||||||
|
srcRaw := addressutil.TonRawAddressObjectKey(in.SrcAddr)
|
||||||
|
if token, ok := jettonWalletTokens[srcRaw]; ok {
|
||||||
|
transfer, err := parseTonJettonTransferNotification(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
amount := tonRawAmountToFloat(transfer.Amount.Nano(), token.Decimals)
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if token.MinAmount > 0 && amount < token.MinAmount {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &TonObservedTransfer{
|
||||||
|
ReceiveAddress: addressutil.NormalizeTonAddressObject(receive),
|
||||||
|
Token: token,
|
||||||
|
RawAmount: transfer.Amount.Nano(),
|
||||||
|
Amount: amount,
|
||||||
|
BlockTimeMs: blockTimeMs,
|
||||||
|
LT: tx.LT,
|
||||||
|
TxHashHex: txHashHex,
|
||||||
|
BlockID: blockID,
|
||||||
|
SenderAddress: addressutil.NormalizeTonAddressObject(transfer.Sender),
|
||||||
|
JettonWallet: addressutil.NormalizeTonAddressObject(in.SrcAddr),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tonPayloadIsTransferNotification(in.Body) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if nativeToken == nil || nativeToken.ID == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rawNano := in.Amount.Nano()
|
||||||
|
if rawNano == nil || rawNano.Sign() <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
amount := tonRawAmountToFloat(rawNano, nativeToken.Decimals)
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if nativeToken.MinAmount > 0 && amount < nativeToken.MinAmount {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &TonObservedTransfer{
|
||||||
|
ReceiveAddress: addressutil.NormalizeTonAddressObject(receive),
|
||||||
|
Token: *nativeToken,
|
||||||
|
RawAmount: rawNano,
|
||||||
|
Amount: amount,
|
||||||
|
BlockTimeMs: blockTimeMs,
|
||||||
|
LT: tx.LT,
|
||||||
|
TxHashHex: txHashHex,
|
||||||
|
BlockID: blockID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TryProcessTonTransfer(transfer *TonObservedTransfer) {
|
||||||
|
if transfer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenSym := strings.ToUpper(strings.TrimSpace(transfer.Token.Symbol))
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
log.Sugar.Errorf("[TON-%s][%s] TryProcessTonTransfer panic: %v", tokenSym, transfer.ReceiveAddress, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
receive := strings.TrimSpace(transfer.ReceiveAddress)
|
||||||
|
if tokenSym == "" || receive == "" || transfer.Amount <= 0 || transfer.BlockID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Infof("[TON-%s][%s] observed transfer tx=%s lt=%d amount=%.6f", tokenSym, receive, transfer.BlockID, transfer.LT, transfer.Amount)
|
||||||
|
|
||||||
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, receive, tokenSym, transfer.Amount)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] lock lookup: %v", tokenSym, receive, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tradeID == "" {
|
||||||
|
log.Sugar.Infof("[TON-%s][%s] skip unmatched transfer tx=%s amount=%.6f", tokenSym, receive, transfer.BlockID, transfer.Amount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] load order: %v", tokenSym, receive, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTon {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s network=%q", tokenSym, receive, tradeID, order.Network)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s token mismatch order=%s", tokenSym, receive, tradeID, order.Token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tonOrderAddressMatches(order.ReceiveAddress, receive) {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s receive address mismatch order=%s", tokenSym, receive, tradeID, order.ReceiveAddress)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if transfer.BlockTimeMs > 0 && transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] skip tx %s because block time %d is before order create time %d", tokenSym, receive, transfer.BlockID, transfer.BlockTimeMs, order.CreatedAt.TimestampMilli())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
Token: tokenSym,
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
TradeId: tradeID,
|
||||||
|
Amount: transfer.Amount,
|
||||||
|
BlockTransactionId: transfer.BlockID,
|
||||||
|
}
|
||||||
|
err = OrderProcessing(req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||||
|
log.Sugar.Infof("[TON-%s][%s] skip resolved transfer trade_id=%s tx=%s err=%v", tokenSym, receive, tradeID, transfer.BlockID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Errorf("[TON-%s][%s] OrderProcessing trade_id=%s tx=%s: %v", tokenSym, receive, tradeID, transfer.BlockID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPaymentNotification(order)
|
||||||
|
log.Sugar.Infof("[TON-%s][%s] payment processed trade_id=%s tx=%s", tokenSym, receive, tradeID, transfer.BlockID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonOrderAddressMatches(a, b string) bool {
|
||||||
|
ar, errA := addressutil.TonRawAddressKey(a)
|
||||||
|
br, errB := addressutil.TonRawAddressKey(b)
|
||||||
|
return errA == nil && errB == nil && ar == br
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonRawAmountToFloat(rawAmount *big.Int, decimals int) float64 {
|
||||||
|
if rawAmount == nil || rawAmount.Sign() <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if decimals < 0 {
|
||||||
|
decimals = 0
|
||||||
|
}
|
||||||
|
amount := decimal.NewFromBigInt(rawAmount, -int32(decimals))
|
||||||
|
return math.MustParsePrecFloat64(amount.InexactFloat64(), data.MaxAmountPrecision)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTonJettonTransferNotification(in *tlb.InternalMessage) (*jetton.TransferNotification, error) {
|
||||||
|
if in == nil || in.Body == nil {
|
||||||
|
return nil, fmt.Errorf("missing jetton transfer notification body")
|
||||||
|
}
|
||||||
|
var transfer jetton.TransferNotification
|
||||||
|
if err := tlb.LoadFromCell(&transfer, in.Body.BeginParse()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonPayloadIsTransferNotification(body *cell.Cell) bool {
|
||||||
|
if body == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
op, err := body.BeginParse().LoadUInt(32)
|
||||||
|
return err == nil && op == TonTransferNotificationOpcode
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTonTransactionBounced(tx *tlb.Transaction) bool {
|
||||||
|
if tx == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if dsc, ok := tx.Description.(tlb.TransactionDescriptionOrdinary); ok && dsc.BouncePhase != nil {
|
||||||
|
if _, ok = dsc.BouncePhase.Phase.(tlb.BouncePhaseOk); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureTonTransferMatchesOrder(order *mdb.Orders, transfer *TonObservedTransfer) error {
|
||||||
|
if order == nil || order.ID == 0 {
|
||||||
|
return fmt.Errorf("order not found")
|
||||||
|
}
|
||||||
|
if transfer == nil {
|
||||||
|
return fmt.Errorf("matching ton transfer to order address not found")
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTon {
|
||||||
|
return fmt.Errorf("order network mismatch")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(order.Token, transfer.Token.Symbol) {
|
||||||
|
return fmt.Errorf("transaction token mismatch")
|
||||||
|
}
|
||||||
|
if !tonOrderAddressMatches(order.ReceiveAddress, transfer.ReceiveAddress) {
|
||||||
|
return fmt.Errorf("transaction recipient mismatch")
|
||||||
|
}
|
||||||
|
if transfer.BlockTimeMs <= 0 || transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() {
|
||||||
|
return fmt.Errorf("transaction predates the order")
|
||||||
|
}
|
||||||
|
if !amountMatchesRaw(order.ActualAmount, transfer.RawAmount, transfer.Token.Decimals) {
|
||||||
|
return fmt.Errorf("transaction amount mismatch")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
|
"github.com/xssnick/tonutils-go/tlb"
|
||||||
|
"github.com/xssnick/tonutils-go/ton/jetton"
|
||||||
|
"github.com/xssnick/tonutils-go/tvm/cell"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tonTestInboundTx(t *testing.T, src, dst *address.Address, amount tlb.Coins, body *cell.Cell) *tlb.Transaction {
|
||||||
|
t.Helper()
|
||||||
|
return &tlb.Transaction{
|
||||||
|
LT: 123,
|
||||||
|
Now: uint32(time.Now().Unix()),
|
||||||
|
Hash: bytes.Repeat([]byte{0x11}, 32),
|
||||||
|
IO: struct {
|
||||||
|
In *tlb.Message `tlb:"maybe ^"`
|
||||||
|
Out *tlb.MessagesList `tlb:"maybe ^"`
|
||||||
|
}{
|
||||||
|
In: &tlb.Message{
|
||||||
|
MsgType: tlb.MsgTypeInternal,
|
||||||
|
Msg: &tlb.InternalMessage{
|
||||||
|
SrcAddr: src,
|
||||||
|
DstAddr: dst,
|
||||||
|
Amount: amount,
|
||||||
|
Body: body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTonInboundTransferNativeTON(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x22}, 32)).Bounce(false).Testnet(false)
|
||||||
|
native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}
|
||||||
|
|
||||||
|
tx := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), cell.BeginCell().EndCell())
|
||||||
|
transfer, err := ParseTonInboundTransfer(tx, receive, native, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
||||||
|
}
|
||||||
|
if transfer == nil {
|
||||||
|
t.Fatal("expected native TON transfer")
|
||||||
|
}
|
||||||
|
if transfer.Token.Symbol != "TON" {
|
||||||
|
t.Fatalf("token = %q, want TON", transfer.Token.Symbol)
|
||||||
|
}
|
||||||
|
if transfer.Amount != 1.23 {
|
||||||
|
t.Fatalf("amount = %v, want 1.23", transfer.Amount)
|
||||||
|
}
|
||||||
|
if transfer.ReceiveAddress != addressutil.NormalizeTonAddressObject(receive) {
|
||||||
|
t.Fatalf("receive address = %q", transfer.ReceiveAddress)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(transfer.BlockID, "ton:"+addressutil.TonRawAddressObjectKey(receive)+":123:") {
|
||||||
|
t.Fatalf("block id = %q, want canonical TON prefix", transfer.BlockID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTonInboundTransferJettonNotification(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false)
|
||||||
|
jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x44}, 32)).Bounce(false).Testnet(false)
|
||||||
|
native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}
|
||||||
|
usdt := mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", Decimals: 6, Enabled: true}
|
||||||
|
|
||||||
|
body, err := tlb.ToCell(jetton.TransferNotification{
|
||||||
|
QueryID: 7,
|
||||||
|
Amount: tlb.MustFromNano(big.NewInt(1230000), 6),
|
||||||
|
Sender: sender,
|
||||||
|
ForwardPayload: cell.BeginCell().EndCell(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build jetton notification body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body)
|
||||||
|
transfer, err := ParseTonInboundTransfer(tx, receive, native, map[string]mdb.ChainToken{
|
||||||
|
addressutil.TonRawAddressObjectKey(jettonWallet): usdt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
||||||
|
}
|
||||||
|
if transfer == nil {
|
||||||
|
t.Fatal("expected USDT jetton transfer")
|
||||||
|
}
|
||||||
|
if transfer.Token.Symbol != "USDT" {
|
||||||
|
t.Fatalf("token = %q, want USDT", transfer.Token.Symbol)
|
||||||
|
}
|
||||||
|
if transfer.Amount != 1.23 {
|
||||||
|
t.Fatalf("amount = %v, want 1.23", transfer.Amount)
|
||||||
|
}
|
||||||
|
if transfer.SenderAddress != addressutil.NormalizeTonAddressObject(sender) {
|
||||||
|
t.Fatalf("sender address = %q", transfer.SenderAddress)
|
||||||
|
}
|
||||||
|
if transfer.JettonWallet != addressutil.NormalizeTonAddressObject(jettonWallet) {
|
||||||
|
t.Fatalf("jetton wallet = %q", transfer.JettonWallet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTonInboundTransferJettonNotificationWithNoGasComputeSkip(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false)
|
||||||
|
jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x44}, 32)).Bounce(false).Testnet(false)
|
||||||
|
usdt := mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", Decimals: 6, Enabled: true}
|
||||||
|
|
||||||
|
body, err := tlb.ToCell(jetton.TransferNotification{
|
||||||
|
QueryID: 7,
|
||||||
|
Amount: tlb.MustFromNano(big.NewInt(150000), 6),
|
||||||
|
Sender: sender,
|
||||||
|
ForwardPayload: cell.BeginCell().EndCell(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build jetton notification body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body)
|
||||||
|
tx.Description = tlb.TransactionDescriptionOrdinary{
|
||||||
|
ComputePhase: tlb.ComputePhase{Phase: tlb.ComputePhaseSkipped{
|
||||||
|
Reason: tlb.ComputeSkipReason{Type: tlb.ComputeSkipReasonNoGas},
|
||||||
|
}},
|
||||||
|
Aborted: true,
|
||||||
|
}
|
||||||
|
transfer, err := ParseTonInboundTransfer(tx, receive, nil, map[string]mdb.ChainToken{
|
||||||
|
addressutil.TonRawAddressObjectKey(jettonWallet): usdt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
||||||
|
}
|
||||||
|
if transfer == nil {
|
||||||
|
t.Fatal("expected no-gas jetton notification to be accepted")
|
||||||
|
}
|
||||||
|
if transfer.Token.Symbol != "USDT" {
|
||||||
|
t.Fatalf("token = %q, want USDT", transfer.Token.Symbol)
|
||||||
|
}
|
||||||
|
if transfer.Amount != 0.15 {
|
||||||
|
t.Fatalf("amount = %v, want 0.15", transfer.Amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTonInboundTransferDoesNotCountUntrustedJettonNotificationAsTON(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false)
|
||||||
|
untrustedJettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x55}, 32)).Bounce(false).Testnet(false)
|
||||||
|
native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}
|
||||||
|
|
||||||
|
body, err := tlb.ToCell(jetton.TransferNotification{
|
||||||
|
QueryID: 7,
|
||||||
|
Amount: tlb.MustFromNano(big.NewInt(1230000), 6),
|
||||||
|
Sender: sender,
|
||||||
|
ForwardPayload: cell.BeginCell().EndCell(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build jetton notification body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := tonTestInboundTx(t, untrustedJettonWallet, receive, tlb.FromNanoTONU(1), body)
|
||||||
|
transfer, err := ParseTonInboundTransfer(tx, receive, native, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
||||||
|
}
|
||||||
|
if transfer != nil {
|
||||||
|
t.Fatalf("untrusted jetton notification produced transfer: %#v", transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-46
@@ -2,9 +2,7 @@ package mq
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,6 +11,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/GMWalletApp/epusdt/util/sign"
|
"github.com/GMWalletApp/epusdt/util/sign"
|
||||||
@@ -25,18 +24,12 @@ import (
|
|||||||
// other secret would produce a signature the merchant can't verify.
|
// other secret would produce a signature the merchant can't verify.
|
||||||
// The admin can resend the callback after fixing the key.
|
// The admin can resend the callback after fixing the key.
|
||||||
func resolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
|
func resolveOrderApiKey(order *mdb.Orders) (*mdb.ApiKey, error) {
|
||||||
if order.ApiKeyID == 0 {
|
row, err := service.ResolveOrderApiKey(order)
|
||||||
return nil, fmt.Errorf("order trade_id=%s has no api_key_id", order.TradeId)
|
if err != nil || row == nil || row.ID == 0 {
|
||||||
}
|
if err != nil {
|
||||||
row, err := data.GetApiKeyByID(order.ApiKeyID)
|
return nil, err
|
||||||
if err != nil {
|
}
|
||||||
return nil, fmt.Errorf("lookup api_key_id=%d: %w", order.ApiKeyID, err)
|
return nil, errors.New("api key unavailable")
|
||||||
}
|
|
||||||
if row.ID == 0 {
|
|
||||||
return nil, fmt.Errorf("api_key_id=%d not found (deleted?)", order.ApiKeyID)
|
|
||||||
}
|
|
||||||
if row.Status != mdb.ApiKeyStatusEnable {
|
|
||||||
return nil, fmt.Errorf("api_key_id=%d is disabled", order.ApiKeyID)
|
|
||||||
}
|
}
|
||||||
return row, nil
|
return row, nil
|
||||||
}
|
}
|
||||||
@@ -48,10 +41,12 @@ const sqliteBusyRetryAttempts = 3
|
|||||||
type expirableOrder struct {
|
type expirableOrder struct {
|
||||||
ID uint64 `gorm:"column:id"`
|
ID uint64 `gorm:"column:id"`
|
||||||
TradeId string `gorm:"column:trade_id"`
|
TradeId string `gorm:"column:trade_id"`
|
||||||
|
Status int `gorm:"column:status"`
|
||||||
Network string `gorm:"column:network"`
|
Network string `gorm:"column:network"`
|
||||||
ReceiveAddress string `gorm:"column:receive_address"`
|
ReceiveAddress string `gorm:"column:receive_address"`
|
||||||
Token string `gorm:"column:token"`
|
Token string `gorm:"column:token"`
|
||||||
ActualAmount float64 `gorm:"column:actual_amount"`
|
ActualAmount float64 `gorm:"column:actual_amount"`
|
||||||
|
PayProvider string `gorm:"column:pay_provider"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func runOrderExpirationLoop() {
|
func runOrderExpirationLoop() {
|
||||||
@@ -91,8 +86,8 @@ func processExpiredOrders() {
|
|||||||
var orders []expirableOrder
|
var orders []expirableOrder
|
||||||
err := withSQLiteBusyRetry(func() error {
|
err := withSQLiteBusyRetry(func() error {
|
||||||
return dao.Mdb.Model(&mdb.Orders{}).
|
return dao.Mdb.Model(&mdb.Orders{}).
|
||||||
Select("id", "trade_id", "network", "receive_address", "token", "actual_amount").
|
Select("id", "trade_id", "status", "network", "receive_address", "token", "actual_amount", "pay_provider").
|
||||||
Where("status = ?", mdb.StatusWaitPay).
|
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
||||||
Where("created_at <= ?", expirationCutoff).
|
Where("created_at <= ?", expirationCutoff).
|
||||||
Order("id asc").
|
Order("id asc").
|
||||||
Limit(batchSize).
|
Limit(batchSize).
|
||||||
@@ -115,6 +110,17 @@ func processExpiredOrders() {
|
|||||||
if !expired {
|
if !expired {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// StatusWaitSelect placeholders have not reserved a wallet or
|
||||||
|
// amount yet, so there is no transaction lock to release.
|
||||||
|
if order.Status == mdb.StatusWaitSelect {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(order.PayProvider) != "" && order.PayProvider != mdb.PaymentProviderOnChain {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(order.Network) == "" || strings.TrimSpace(order.ReceiveAddress) == "" || strings.TrimSpace(order.Token) == "" || order.ActualAmount <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
||||||
log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err)
|
log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err)
|
||||||
}
|
}
|
||||||
@@ -192,40 +198,13 @@ func sendOrderCallback(order *mdb.Orders) error {
|
|||||||
return errors.New("no api key row available for callback")
|
return errors.New("no api key row available for callback")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch order.PaymentType {
|
switch {
|
||||||
case mdb.PaymentTypeEpay:
|
case strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay):
|
||||||
// EPAY uses pid (integer) and secret_key as "key".
|
formData, err := service.BuildEPayResultParams(order, apiKeyRow)
|
||||||
pidInt, convErr := strconv.Atoi(apiKeyRow.Pid)
|
|
||||||
if convErr != nil {
|
|
||||||
return fmt.Errorf("epay pid not numeric: %s", apiKeyRow.Pid)
|
|
||||||
}
|
|
||||||
notifyData := response.OrderNotifyResponseEpay{
|
|
||||||
PID: pidInt,
|
|
||||||
TradeNo: order.TradeId,
|
|
||||||
OutTradeNo: order.OrderId,
|
|
||||||
Type: "alipay",
|
|
||||||
Name: order.Name,
|
|
||||||
Money: fmt.Sprintf("%.4f", order.Amount),
|
|
||||||
TradeStatus: "TRADE_SUCCESS",
|
|
||||||
}
|
|
||||||
|
|
||||||
signstr2, err := sign.Get(notifyData, apiKeyRow.SecretKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
formData := map[string]string{
|
|
||||||
"pid": fmt.Sprintf("%d", notifyData.PID),
|
|
||||||
"trade_no": notifyData.TradeNo,
|
|
||||||
"out_trade_no": notifyData.OutTradeNo,
|
|
||||||
"type": notifyData.Type,
|
|
||||||
"name": notifyData.Name,
|
|
||||||
"money": notifyData.Money,
|
|
||||||
"trade_status": notifyData.TradeStatus,
|
|
||||||
"sign": signstr2,
|
|
||||||
"sign_type": "MD5",
|
|
||||||
}
|
|
||||||
|
|
||||||
epayResp, err := http_client.GetHttpClient().R().SetQueryParams(formData).Get(order.NotifyUrl)
|
epayResp, err := http_client.GetHttpClient().R().SetQueryParams(formData).Get(order.NotifyUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+26
-3
@@ -62,6 +62,21 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
t.Fatalf("lock recent order: %v", err)
|
t.Fatalf("lock recent order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
placeholder := &mdb.Orders{
|
||||||
|
TradeId: "trade_wait_select_expired",
|
||||||
|
OrderId: "order_wait_select_expired",
|
||||||
|
Amount: 1,
|
||||||
|
Currency: "CNY",
|
||||||
|
Status: mdb.StatusWaitSelect,
|
||||||
|
NotifyUrl: "https://merchant.example/callback",
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(placeholder).Error; err != nil {
|
||||||
|
t.Fatalf("create placeholder order: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Model(placeholder).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil {
|
||||||
|
t.Fatalf("age placeholder order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
processExpiredOrders()
|
processExpiredOrders()
|
||||||
|
|
||||||
expired, err := data.GetOrderInfoByTradeId(order.TradeId)
|
expired, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||||
@@ -93,6 +108,14 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
if lockTradeID != recentOrder.TradeId {
|
if lockTradeID != recentOrder.TradeId {
|
||||||
t.Fatalf("recent order lock = %s, want %s", lockTradeID, recentOrder.TradeId)
|
t.Fatalf("recent order lock = %s, want %s", lockTradeID, recentOrder.TradeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expiredPlaceholder, err := data.GetOrderInfoByTradeId(placeholder.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload expired placeholder: %v", err)
|
||||||
|
}
|
||||||
|
if expiredPlaceholder.Status != mdb.StatusExpired {
|
||||||
|
t.Fatalf("placeholder status = %d, want %d", expiredPlaceholder.Status, mdb.StatusExpired)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessExpiredOrdersKeepsPaidOrdersPaid(t *testing.T) {
|
func TestProcessExpiredOrdersKeepsPaidOrdersPaid(t *testing.T) {
|
||||||
@@ -295,7 +318,7 @@ func TestDispatchPendingCallbacksEpayRequiresAck(t *testing.T) {
|
|||||||
BlockTransactionId: "block_callback_epay_fail",
|
BlockTransactionId: "block_callback_epay_fail",
|
||||||
CallbackNum: 0,
|
CallbackNum: 0,
|
||||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||||
PaymentType: mdb.PaymentTypeEpay,
|
PaymentType: "epay",
|
||||||
ApiKeyID: epayKey.ID,
|
ApiKeyID: epayKey.ID,
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
@@ -349,7 +372,7 @@ func TestDispatchPendingCallbacksEpayAcceptsTrimmedOk(t *testing.T) {
|
|||||||
BlockTransactionId: "block_callback_epay_ok",
|
BlockTransactionId: "block_callback_epay_ok",
|
||||||
CallbackNum: 0,
|
CallbackNum: 0,
|
||||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||||
PaymentType: mdb.PaymentTypeEpay,
|
PaymentType: "epay",
|
||||||
ApiKeyID: epayKey.ID,
|
ApiKeyID: epayKey.ID,
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
@@ -550,7 +573,7 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
|
|||||||
Status: mdb.StatusPaySuccess,
|
Status: mdb.StatusPaySuccess,
|
||||||
NotifyUrl: server.URL,
|
NotifyUrl: server.URL,
|
||||||
BlockTransactionId: "block_epay_sign",
|
BlockTransactionId: "block_epay_sign",
|
||||||
PaymentType: mdb.PaymentTypeEpay,
|
PaymentType: "epay",
|
||||||
ApiKeyID: key.ID,
|
ApiKeyID: key.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1106
-7
File diff suppressed because it is too large
Load Diff
+44
-14
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/controller/admin"
|
"github.com/GMWalletApp/epusdt/controller/admin"
|
||||||
"github.com/GMWalletApp/epusdt/controller/comm"
|
"github.com/GMWalletApp/epusdt/controller/comm"
|
||||||
@@ -22,24 +23,38 @@ import (
|
|||||||
|
|
||||||
// RegisterRoute 路由注册
|
// RegisterRoute 路由注册
|
||||||
func RegisterRoute(e *echo.Echo) {
|
func RegisterRoute(e *echo.Echo) {
|
||||||
e.Any("/", func(c echo.Context) error {
|
e.POST("/", func(c echo.Context) error {
|
||||||
return c.String(http.StatusOK, "hello epusdt, https://github.com/GMwalletApp/epusdt")
|
return c.String(http.StatusOK, "hello epusdt, https://github.com/GMwalletApp/epusdt")
|
||||||
})
|
})
|
||||||
|
|
||||||
payRoute := e.Group("/pay")
|
payRoute := e.Group("/pay")
|
||||||
payRoute.GET("/checkout-counter/:trade_id", comm.Ctrl.CheckoutCounter)
|
payRoute.GET("/checkout-counter/:trade_id", func(ctx echo.Context) error {
|
||||||
|
tradeId := ctx.Param("trade_id")
|
||||||
|
|
||||||
|
targetURL := fmt.Sprintf("/cashier/%s", tradeId)
|
||||||
|
|
||||||
|
return ctx.Redirect(http.StatusMovedPermanently, targetURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
payRoute.GET("/checkout-counter-resp/:trade_id", comm.Ctrl.CheckoutCounter)
|
||||||
|
payRoute.GET("/return/:trade_id", comm.Ctrl.ReturnToMerchant)
|
||||||
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
|
payRoute.GET("/check-status/:trade_id", comm.Ctrl.CheckStatus)
|
||||||
|
payRoute.POST("/submit-tx-hash/:trade_id", comm.Ctrl.SubmitTxHash)
|
||||||
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
|
payRoute.POST("/switch-network", comm.Ctrl.SwitchNetwork)
|
||||||
|
|
||||||
// payment routes
|
// payment routes
|
||||||
paymentRoute := e.Group("/payments")
|
paymentRoute := e.Group("/payments")
|
||||||
|
|
||||||
// gmpay v1 routes
|
// gmpay v1 routes. CreateTransaction accepts token+network together
|
||||||
|
// for a concrete chain order, or omits both for a status=4 placeholder
|
||||||
|
// that is completed later by /pay/switch-network or routed to OkPay.
|
||||||
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
||||||
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
||||||
gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
|
gmpayV1.GET("/config", comm.Ctrl.GetPublicConfig)
|
||||||
// gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords)
|
|
||||||
// gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset)
|
// okpay v1 routes
|
||||||
|
okpayV1 := paymentRoute.Group("/okpay/v1")
|
||||||
|
okpayV1.POST("/notify", comm.Ctrl.OkPayNotify)
|
||||||
|
|
||||||
// epay v1 routes
|
// epay v1 routes
|
||||||
//
|
//
|
||||||
@@ -64,7 +79,7 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
|
|
||||||
formParams, err := ctx.FormParams()
|
formParams, err := ctx.FormParams()
|
||||||
if err != nil && ctx.Request().Method == http.MethodPost {
|
if err != nil && ctx.Request().Method == http.MethodPost {
|
||||||
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid epay form params: %w", err))
|
return comm.Ctrl.FailJson(ctx, constant.ParamsMarshalErr)
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
copyParams(formParams)
|
copyParams(formParams)
|
||||||
@@ -118,16 +133,31 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
notifyURL := getString(params, "notify_url")
|
notifyURL := getString(params, "notify_url")
|
||||||
outTradeNo := getString(params, "out_trade_no")
|
outTradeNo := getString(params, "out_trade_no")
|
||||||
returnURL := getString(params, "return_url")
|
returnURL := getString(params, "return_url")
|
||||||
|
token := strings.TrimSpace(getString(params, "token"))
|
||||||
|
if token == "" {
|
||||||
|
token = data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "")
|
||||||
|
}
|
||||||
|
network := strings.TrimSpace(getString(params, "network"))
|
||||||
|
if network == "" {
|
||||||
|
network = data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "")
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(getString(params, "currency"))
|
||||||
|
if currency == "" {
|
||||||
|
currency = data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "")
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
currency = "cny"
|
||||||
|
}
|
||||||
|
|
||||||
amountFloat, err := strconv.ParseFloat(money, 64)
|
amountFloat, err := strconv.ParseFloat(money, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return comm.Ctrl.FailJson(ctx, fmt.Errorf("invalid money value: %s", money))
|
return comm.Ctrl.FailJson(ctx, constant.PayAmountErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"token": data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
|
"token": token,
|
||||||
"currency": data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
"currency": currency,
|
||||||
"network": data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
|
"network": network,
|
||||||
"amount": amountFloat,
|
"amount": amountFloat,
|
||||||
"notify_url": notifyURL,
|
"notify_url": notifyURL,
|
||||||
"order_id": outTradeNo,
|
"order_id": outTradeNo,
|
||||||
@@ -143,7 +173,7 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
|
|
||||||
jsonBytes, err := json.Marshal(body)
|
jsonBytes, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return comm.Ctrl.FailJson(ctx, err)
|
return comm.Ctrl.FailJson(ctx, constant.SystemErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
ctx.Request().Body = io.NopCloser(bytes.NewBuffer(jsonBytes))
|
||||||
@@ -214,8 +244,7 @@ func registerAdminRoutes(e *echo.Echo) {
|
|||||||
authed.POST("/notification-channels/:id/status", admin.Ctrl.ChangeNotificationChannelStatus)
|
authed.POST("/notification-channels/:id/status", admin.Ctrl.ChangeNotificationChannelStatus)
|
||||||
authed.DELETE("/notification-channels/:id", admin.Ctrl.DeleteNotificationChannel)
|
authed.DELETE("/notification-channels/:id", admin.Ctrl.DeleteNotificationChannel)
|
||||||
|
|
||||||
// gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets)
|
authed.GET("/config", comm.Ctrl.GetPublicConfig) // wrap for admin console, same payload as public endpoint
|
||||||
authed.GET("/supported-assets", comm.Ctrl.GetSupportedAssets) // wrap for admin console, same handler as public endpoint
|
|
||||||
|
|
||||||
// Chains
|
// Chains
|
||||||
authed.GET("/chains", admin.Ctrl.ListChains)
|
authed.GET("/chains", admin.Ctrl.ListChains)
|
||||||
@@ -259,6 +288,7 @@ func registerAdminRoutes(e *echo.Echo) {
|
|||||||
authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend)
|
authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend)
|
||||||
authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend)
|
authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend)
|
||||||
authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats)
|
authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats)
|
||||||
|
authed.GET("/dashboard/rpc-stats", admin.Ctrl.RpcStats)
|
||||||
authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders)
|
authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders)
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user