13 Commits

Author SHA1 Message Date
line-6000 7fcb7b38fe docs: update payment API integration guide 2026-05-23 17:23:41 +08:00
line-6000 2a00a0332b Merge pull request #80 from GMWalletApp/dev
Dev
2026-05-22 23:14:35 +08:00
line-6000 8a48611625 feat(rate): support multi-currency forced rate list 2026-05-22 23:13:21 +08:00
line-6000 dec1b97b9e fix: retry Solana signatures after transient processing errors
Avoid marking a Solana signature as processed when order matching or
OrderProcessing fails with a retryable error. Keep terminal outcomes
idempotent while allowing later scans to retry transient failures.
2026-05-22 16:37:50 +08:00
line-6000 449d22a41b fix: send OkPay payment success notifications 2026-05-22 16:25:23 +08:00
line-6000 4b4701f5b9 fix: skip stale EVM ERC20 transfers before order creation 2026-05-22 15:13:09 +08:00
line-6000 f4ef71b359 fix: accept bare admin JWT authorization header
Allow the admin JWT middleware to parse either Authorization: Bearer <jwt>
or a bare JWT value, matching the existing comment. Add tests covering both
accepted forms plus missing and empty authorization headers.
2026-05-22 15:03:59 +08:00
line-6000 c19a7e5f2c Merge pull request #73 from GMWalletApp/dev
update www
2026-05-21 19:39:48 +08:00
line-6000 09642d4c2b update www 2026-05-21 19:38:42 +08:00
line-6000 9f9b258d59 Merge pull request #72 from GMWalletApp/dev
feat: expose build version in public config
2026-05-21 19:23:58 +08:00
line-6000 85b81e661c feat: expose build version in public config
- add version to public/admin config responses
- inject build version in Docker and GoReleaser builds
- limit Docker image publishing to v* tags
- restore soft-deleted settings on upsert
2026-05-21 19:23:04 +08:00
line-6000 049a15cb95 Merge pull request #70 from GMWalletApp/dev
feat: verify manual mark-paid payments
2026-05-21 00:07:09 +08:00
line-6000 2a36a10e7f feat: verify manual mark-paid payments
- add on-chain validation before manual mark-paid
- verify recipient address, token contract, amount, confirmations, and tx time
- reject duplicate transaction hashes and non on-chain orders
- add manual payment verification tests for EVM, TRON, and Solana
- change BSC network key to binance
- add display_name to supported_assets while keeping network unchanged
2026-05-20 23:48:00 +08:00
323 changed files with 5093 additions and 591 deletions
+1 -5
View File
@@ -1,11 +1,7 @@
name: docker-alpine-epusdt name: docker-alpine-epusdt
on: on:
workflow_dispatch:
push: push:
branches:
- "main"
- "master"
tags: tags:
- "v*" - "v*"
@@ -34,7 +30,6 @@ jobs:
tags: | tags: |
type=raw,value=alpine type=raw,value=alpine
type=raw,value=latest type=raw,value=latest
type=ref,event=branch,suffix=-alpine
type=ref,event=tag type=ref,event=tag
type=sha,prefix=sha-,suffix=-alpine type=sha,prefix=sha-,suffix=-alpine
@@ -53,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 }}
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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
+20 -23
View File
@@ -8,7 +8,6 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -17,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
@@ -353,21 +346,25 @@ 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 {
return 1 / usdtRate
}
return 0
}
} }
return getRateForCoinFromAPI(coin, base) return getRateForCoinFromAPI(coin, base)
} }
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
}
func getRateForCoinFromAPI(coin string, base string) float64 { func getRateForCoinFromAPI(coin string, base string) float64 {
baseURL := GetRateApiUrl() baseURL := GetRateApiUrl()
if baseURL == "" { if baseURL == "" {
@@ -401,17 +398,17 @@ func getRateForCoinFromAPI(coin string, base string) float64 {
} }
func GetUsdtRate() float64 { func GetUsdtRate() float64 {
// Only the admin setting can force the USDT/CNY rate. When the // rate.forced_rate_list stores token amount per one base currency unit.
// setting is unset, zero, or negative, fall back to the rate API. // 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
} }
apiRate := getRateForCoinFromAPI("usdt", "cny") apiRate := getRateForCoinFromAPI("usdt", "cny")
if apiRate > 0 { if apiRate > 0 {
return 1 / apiRate return 1 / apiRate
} }
log.Printf("usdt/cny rate unavailable: rate.forced_usdt_rate <= 0 and rate api returned no data") log.Printf("usdt/cny rate unavailable: rate.forced_rate_list has no positive cny.usdt entry and rate api returned no data")
return 0 return 0
} }
+39 -7
View File
@@ -231,7 +231,7 @@ func TestCopyMissingStaticFilesCopiesAssetsWithoutOverwriting(t *testing.T) {
} }
} }
func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) { func TestGetUsdtRatePrefersForcedRateList(t *testing.T) {
viper.Reset() viper.Reset()
t.Cleanup(viper.Reset) t.Cleanup(viper.Reset)
t.Setenv("API_RATE_URL", "") t.Setenv("API_RATE_URL", "")
@@ -249,16 +249,19 @@ func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
}) })
installSettingsGetter(t, map[string]string{ installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "7.25", "rate.forced_rate_list": `{"cny":{"usdt":0.13793103448275862},"usd":{"trx":123.45}}`,
"rate.api_url": "https://rate.example.test", "rate.api_url": "https://rate.example.test",
}) })
got := GetUsdtRate() got := GetUsdtRate()
if got != 7.25 { if math.Abs(got-7.25) > 1e-9 {
t.Fatalf("GetUsdtRate() = %v, want 7.25", got) 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 { if apiCalled {
t.Fatalf("rate API should not be called when rate.forced_usdt_rate > 0") t.Fatalf("rate API should not be called when rate.forced_rate_list has positive rate")
} }
} }
@@ -281,7 +284,7 @@ func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
}) })
installSettingsGetter(t, map[string]string{ installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "-1", "rate.forced_rate_list": `{"cny":{"usdt":0}}`,
"rate.api_url": "https://rate.example.test", "rate.api_url": "https://rate.example.test",
}) })
@@ -297,6 +300,35 @@ func TestGetUsdtRateUsesAPIWhenAdminOverrideIsNotPositive(t *testing.T) {
} }
} }
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 TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) { func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
viper.Reset() viper.Reset()
t.Cleanup(viper.Reset) t.Cleanup(viper.Reset)
@@ -313,7 +345,7 @@ func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing
}) })
installSettingsGetter(t, map[string]string{ installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0", "rate.forced_rate_list": `{"cny":{"usdt":0}}`,
"rate.api_url": "https://rate.example.test", "rate.api_url": "https://rate.example.test",
}) })
@@ -343,7 +375,7 @@ func TestGetRateForCoinCallsRateAPIOnceForUsdtCnyFailure(t *testing.T) {
}) })
installSettingsGetter(t, map[string]string{ installSettingsGetter(t, map[string]string{
"rate.forced_usdt_rate": "0", "rate.forced_rate_list": `{"cny":{"usdt":0}}`,
"rate.api_url": "https://rate.example.test", "rate.api_url": "https://rate.example.test",
}) })
+3 -11
View File
@@ -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
} }
+56 -3
View File
@@ -13,6 +13,7 @@ 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/log"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -147,23 +148,43 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post] // @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error { func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
tradeID := ctx.Param("trade_id") tradeID := ctx.Param("trade_id")
adminUserID := currentAdminUserID(ctx)
req := new(MarkPaidRequest) req := new(MarkPaidRequest)
if err := ctx.Bind(req); err != nil { if err := ctx.Bind(req); err != nil {
log.Sugar.Warnf("[admin-order] mark-paid bind failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
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) order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil { if err != nil {
log.Sugar.Warnf("[admin-order] mark-paid load failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
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")) err = errors.New("order not found")
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
} }
if order.Status != mdb.StatusWaitPay { if order.Status != mdb.StatusWaitPay {
return c.FailJson(ctx, errors.New("order is not waiting payment")) err = errors.New("order is not waiting payment")
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s status=%d block_transaction_id=%s err=%v", adminUserID, tradeID, order.Status, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
} }
if !isOnChainOrder(order.PayProvider) {
err = errors.New("order is not an on-chain payment order")
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s pay_provider=%s block_transaction_id=%s err=%v", adminUserID, tradeID, order.PayProvider, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
verifiedBlockTransactionID, err := service.ValidateManualOrderPayment(order, req.BlockTransactionId)
if err != nil {
log.Sugar.Warnf("[admin-order] mark-paid chain verification failed admin_user_id=%d trade_id=%s network=%s token=%s amount=%.8f block_transaction_id=%s err=%v", adminUserID, tradeID, order.Network, order.Token, order.ActualAmount, req.BlockTransactionId, err)
return c.FailJson(ctx, err)
}
req.BlockTransactionId = verifiedBlockTransactionID
err = service.OrderProcessing(&request.OrderProcessingRequest{ err = service.OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: order.ReceiveAddress, ReceiveAddress: order.ReceiveAddress,
Currency: order.Currency, Currency: order.Currency,
@@ -174,8 +195,10 @@ func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
BlockTransactionId: req.BlockTransactionId, BlockTransactionId: req.BlockTransactionId,
}) })
if err != nil { if err != nil {
log.Sugar.Warnf("[admin-order] mark-paid processing failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
return c.FailJson(ctx, err) return c.FailJson(ctx, err)
} }
log.Sugar.Infof("[admin-order] mark-paid success admin_user_id=%d trade_id=%s block_transaction_id=%s", adminUserID, tradeID, req.BlockTransactionId)
return c.SucJson(ctx, nil) return c.SucJson(ctx, nil)
} }
@@ -193,16 +216,46 @@ 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 = errors.New("order not found")
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 = errors.New("order is not paid (callback not applicable)")
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 = errors.New("order has empty notify_url")
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 = errors.New("resend callback failed (concurrent state change)")
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)
} }
func isOnChainOrder(payProvider string) bool {
payProvider = strings.TrimSpace(payProvider)
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
}
// ExportOrders streams matching orders as CSV. No pagination — the // ExportOrders streams matching orders as CSV. No pagination — the
// caller drives what rows to include via filter params. Capped at // caller drives what rows to include via filter params. Capped at
// 10k rows to keep the response bounded. // 10k rows to keep the response bounded.
+95 -18
View File
@@ -1,6 +1,7 @@
package admin package admin
import ( import (
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
@@ -16,8 +17,8 @@ import (
// Supported groups and keys: // Supported groups and keys:
// //
// - group=rate: // - group=rate:
// rate.forced_usdt_rate (float) — override USDT/CNY when > 0; <= 0 uses rate.api_url // rate.forced_rate_list (json) — override rate map, e.g. {"cny":{"usdt":0.14635}}; base/coin keys are normalized to lowercase
// rate.api_url (string) — external rate API URL used when rate.forced_usdt_rate <= 0 // 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
// //
@@ -52,10 +53,10 @@ import (
// system.order_expiration_time (int) — order expiry in minutes // system.order_expiration_time (int) — order expiry in minutes
// system.amount_precision (int) — payment amount precision, 2-6 decimals (default 2) // system.amount_precision (int) — payment amount precision, 2-6 decimals (default 2)
type SettingUpsertItem struct { type SettingUpsertItem struct {
Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"` Group string `json:"group" enums:"brand,rate,system,epay,okpay" example:"epay"`
Key string `json:"key" example:"epay.default_network"` Key string `json:"key" example:"epay.default_network"`
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.
@@ -92,7 +93,7 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
// @Description Supported groups: brand, rate, system, epay, okpay. // @Description Supported groups: brand, rate, system, epay, okpay.
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron"). // @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
// @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens. // @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens.
// @Description rate group keys: rate.forced_usdt_rate (>0 overrides USDT/CNY; <=0 uses rate.api_url), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled. // @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
// @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported. // @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported.
// @Description system group keys: system.order_expiration_time, system.amount_precision (int, 2-6, default 2). // @Description system group keys: system.order_expiration_time, system.amount_precision (int, 2-6, default 2).
// @Tags Admin Settings // @Tags Admin Settings
@@ -123,7 +124,13 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
out = append(out, result{Key: item.Key, OK: false, Error: "key required"}) out = append(out, result{Key: item.Key, OK: false, Error: "key required"})
continue continue
} }
if err := validateSettingItem(item.Group, key, item.Value); err != nil { value, err := normalizeSettingValue(item.Value)
if err != nil {
out = append(out, result{Key: key, OK: false, Error: err.Error()})
continue
}
value, err = normalizeAndValidateSettingItem(item.Group, key, value)
if err != nil {
out = append(out, result{Key: key, OK: false, Error: err.Error()}) out = append(out, result{Key: key, OK: false, Error: err.Error()})
continue continue
} }
@@ -131,7 +138,11 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
item.Group = mdb.SettingGroupSystem item.Group = mdb.SettingGroupSystem
item.Type = mdb.SettingTypeInt item.Type = mdb.SettingTypeInt
} }
if err := data.SetSetting(item.Group, key, item.Value, item.Type); err != nil { if key == mdb.SettingKeyRateForcedRateList {
item.Group = mdb.SettingGroupRate
item.Type = mdb.SettingTypeJSON
}
if err := data.SetSetting(item.Group, key, value, item.Type); err != nil {
out = append(out, result{Key: key, OK: false, Error: err.Error()}) out = append(out, result{Key: key, OK: false, Error: err.Error()})
continue continue
} }
@@ -159,21 +170,91 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
return c.SucJson(ctx, out) return c.SucJson(ctx, out)
} }
func validateSettingItem(group, key, value string) error { 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 { switch key {
case mdb.SettingKeyAmountPrecision: case mdb.SettingKeyAmountPrecision:
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem { if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
return fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem) return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
} }
precision, err := strconv.Atoi(strings.TrimSpace(value)) precision, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil { if err != nil {
return fmt.Errorf("%s must be an integer", key) return value, fmt.Errorf("%s must be an integer", key)
} }
if precision < data.MinAmountPrecision || precision > data.MaxAmountPrecision { if precision < data.MinAmountPrecision || precision > data.MaxAmountPrecision {
return fmt.Errorf("%s must be between %d and %d", key, data.MinAmountPrecision, 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
}
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
} }
} }
return nil
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
@@ -197,7 +278,3 @@ func (c *BaseAdminController) DeleteSetting(ctx echo.Context) error {
} }
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
+6 -13
View File
@@ -1,8 +1,6 @@
package comm package comm
import ( import (
"net/http"
"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/labstack/echo/v4" "github.com/labstack/echo/v4"
@@ -10,23 +8,18 @@ import (
// CheckoutCounter 收银台 // CheckoutCounter 收银台
// @Summary Checkout counter page // @Summary Checkout counter page
// @Description Render the payment checkout counter JSON response 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).
// @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.CheckoutCounterResponse // @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
// @Failure 400 {object} response.ApiResponse "Order not found (status_code=10008) or other request error"
// @Router /pay/checkout-counter-resp/{trade_id} [get] // @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).
emptyResp := response.CheckoutCounterResponse{}
return c.SucJson(ctx, emptyResp)
}
return ctx.String(http.StatusInternalServerError, err.Error())
} }
return c.SucJson(ctx, resp) return c.SucJson(ctx, resp)
@@ -34,12 +27,12 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
// 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.
// @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")
@@ -5,6 +5,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/middleware" "github.com/GMWalletApp/epusdt/middleware"
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
@@ -53,9 +54,14 @@ func buildSupportedAssets() ([]response.NetworkTokenSupport, 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,
}) })
} }
@@ -107,7 +113,8 @@ func (c *BaseCommController) GetPublicConfig(ctx echo.Context) error {
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"), DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"), DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
}, },
OkPay: okpay, OkPay: okpay,
Version: config.GetAppVersion(),
}) })
} }
+4 -4
View File
@@ -85,11 +85,11 @@ func SetupTestDatabases(t testing.TB) func() {
}) })
if err := dao.Mdb.Create(&mdb.Setting{ if err := dao.Mdb.Create(&mdb.Setting{
Group: "rate", Group: "rate",
Key: "rate.forced_usdt_rate", Key: "rate.forced_rate_list",
Value: "1.0", Value: `{"cny":{"usdt":1}}`,
Type: "string", Type: "json",
}).Error; err != nil { }).Error; err != nil {
t.Fatalf("seed rate.forced_usdt_rate: %v", err) t.Fatalf("seed rate.forced_rate_list: %v", err)
} }
return func() { return func() {
+23 -10
View File
@@ -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
}
+118
View File
@@ -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())
}
})
}
}
+13 -2
View File
@@ -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
} }
+122
View File
@@ -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)
}
}
+50
View File
@@ -108,6 +108,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{}).
+3 -1
View File
@@ -144,9 +144,11 @@ func SetSetting(group, key, value, valueType string) error {
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
+5 -5
View File
@@ -41,7 +41,7 @@ const (
SettingKeyBrandSupportUrl = "brand.support_url" SettingKeyBrandSupportUrl = "brand.support_url"
SettingKeyBrandBackgroundColor = "brand.background_color" SettingKeyBrandBackgroundColor = "brand.background_color"
SettingKeyBrandBackgroundImageUrl = "brand.background_image_url" SettingKeyBrandBackgroundImageUrl = "brand.background_image_url"
SettingKeyRateForcedUsdt = "rate.forced_usdt_rate" 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"
@@ -64,10 +64,10 @@ const (
type Setting struct { type Setting struct {
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system,epay,okpay" example:"rate"` Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system,epay,okpay" example:"rate"`
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_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
} }
+1 -1
View File
@@ -9,7 +9,7 @@ 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"
) )
+4 -2
View File
@@ -1,8 +1,9 @@
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 EpayPublicConfig struct { type EpayPublicConfig struct {
@@ -36,4 +37,5 @@ type PublicConfigResponse struct {
Site SitePublicConfig `json:"site"` Site SitePublicConfig `json:"site"`
Epay EpayPublicConfig `json:"epay"` Epay EpayPublicConfig `json:"epay"`
OkPay OkPayPublicConfig `json:"okpay"` OkPay OkPayPublicConfig `json:"okpay"`
Version string `json:"version" example:"v1.0.1"`
} }
+832
View File
@@ -0,0 +1,832 @@
package service
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"math/big"
"net/http"
"strings"
"sync"
"time"
tron "github.com/GMWalletApp/epusdt/crypto"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/ethclient"
"github.com/shopspring/decimal"
"github.com/tidwall/gjson"
)
const manualVerifyRequestTimeout = 15 * time.Second
var erc20TransferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
var (
manualOrderPaymentValidatorMu sync.Mutex
manualOrderPaymentValidator manualOrderPaymentValidatorFunc = validateManualOrderPaymentDefault
)
type manualOrderPaymentValidatorFunc func(*mdb.Orders, string) (string, error)
// ValidateManualOrderPayment verifies that the supplied chain transaction
// really settles the order before an admin manually marks it paid. It returns
// the canonical transaction id that should be persisted for duplicate checks.
func ValidateManualOrderPayment(order *mdb.Orders, blockTransactionID string) (string, error) {
manualOrderPaymentValidatorMu.Lock()
validator := manualOrderPaymentValidator
manualOrderPaymentValidatorMu.Unlock()
return validator(order, blockTransactionID)
}
// SetManualOrderPaymentValidatorForTest swaps the chain verifier in tests so
// route/controller tests don't depend on public RPC availability.
func SetManualOrderPaymentValidatorForTest(fn func(*mdb.Orders, string) (string, error)) func() {
manualOrderPaymentValidatorMu.Lock()
old := manualOrderPaymentValidator
if fn == nil {
manualOrderPaymentValidator = validateManualOrderPaymentDefault
} else {
manualOrderPaymentValidator = fn
}
manualOrderPaymentValidatorMu.Unlock()
return func() {
manualOrderPaymentValidatorMu.Lock()
manualOrderPaymentValidator = old
manualOrderPaymentValidatorMu.Unlock()
}
}
func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID string) (string, error) {
if order == nil || order.ID == 0 {
return "", fmt.Errorf("order not found")
}
txID := strings.TrimSpace(blockTransactionID)
if txID == "" {
return "", fmt.Errorf("block_transaction_id is required")
}
var canonicalTxID string
var err error
switch strings.ToLower(strings.TrimSpace(order.Network)) {
case mdb.NetworkTron:
canonicalTxID, err = validateManualTronPayment(order, txID)
case mdb.NetworkSolana:
canonicalTxID, err = validateManualSolanaPayment(order, txID)
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
canonicalTxID, err = validateManualEvmPayment(order, txID)
default:
return "", fmt.Errorf("unsupported manual payment verification network: %s", order.Network)
}
if err != nil {
return "", err
}
if err = ensureManualBlockTransactionUnused(order, canonicalTxID); err != nil {
return "", err
}
return canonicalTxID, nil
}
func ensureManualBlockTransactionUnused(order *mdb.Orders, canonicalTxID string) error {
candidates := equivalentManualBlockTransactionIDs(order.Network, canonicalTxID)
var existing *mdb.Orders
var err error
if manualBlockTransactionIDIsHex(order.Network) {
existing, err = data.GetOrderByBlockTransactionIDsCaseInsensitive(candidates)
} else {
existing, err = data.GetOrderByBlockTransactionIDs(candidates)
}
if err != nil {
return err
}
if existing.ID > 0 && existing.ID != order.ID {
return constant.OrderBlockAlreadyProcess
}
return nil
}
func manualBlockTransactionIDIsHex(network string) bool {
switch strings.ToLower(strings.TrimSpace(network)) {
case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
return true
default:
return false
}
}
func equivalentManualBlockTransactionIDs(network, canonicalTxID string) []string {
network = strings.ToLower(strings.TrimSpace(network))
canonicalTxID = strings.TrimSpace(canonicalTxID)
seen := make(map[string]struct{})
out := make([]string, 0, 6)
add := func(value string) {
value = strings.TrimSpace(value)
if value == "" {
return
}
if _, ok := seen[value]; ok {
return
}
seen[value] = struct{}{}
out = append(out, value)
}
add(canonicalTxID)
switch network {
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X")
body = strings.ToLower(body)
add("0x" + body)
add("0x" + strings.ToUpper(body))
add("0X" + body)
add("0X" + strings.ToUpper(body))
add(body)
add(strings.ToUpper(body))
case mdb.NetworkTron:
body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X")
body = strings.ToLower(body)
add(body)
add(strings.ToUpper(body))
add("0x" + body)
add("0x" + strings.ToUpper(body))
add("0X" + body)
add("0X" + strings.ToUpper(body))
}
return out
}
func validateManualEvmPayment(order *mdb.Orders, txID string) (string, error) {
txHash, canonicalTxID, err := canonicalEvmHash(txID)
if err != nil {
return "", err
}
token, err := data.GetEnabledChainTokenBySymbol(order.Network, order.Token)
if err != nil {
return "", err
}
if token == nil || token.ID == 0 || strings.TrimSpace(token.ContractAddress) == "" {
return "", fmt.Errorf("enabled token contract not configured for %s/%s", order.Network, order.Token)
}
ctx, cancel := context.WithTimeout(context.Background(), manualVerifyRequestTimeout)
defer cancel()
clients, err := dialManualEvmClients(ctx, order.Network)
if err != nil {
return "", err
}
defer closeManualEvmClients(clients)
if err = validateManualEvmPaymentAcrossClients(ctx, clients, order, txHash, token); err != nil {
return "", err
}
return canonicalTxID, nil
}
type evmChainReader interface {
TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error)
HeaderByNumber(context.Context, *big.Int) (*types.Header, error)
}
type manualEvmClient struct {
label string
reader evmChainReader
close func()
}
func closeManualEvmClients(clients []manualEvmClient) {
for _, item := range clients {
if item.close != nil {
item.close()
}
}
}
func validateManualEvmPaymentAcrossClients(ctx context.Context, clients []manualEvmClient, order *mdb.Orders, txHash common.Hash, token *mdb.ChainToken) error {
var verifyErrors []string
for _, item := range clients {
if err := validateManualEvmPaymentWithClient(ctx, item.reader, order, txHash, token); err != nil {
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", item.label, err))
continue
}
return nil
}
if len(verifyErrors) > 0 {
return fmt.Errorf("manual EVM verification failed: %s", strings.Join(verifyErrors, "; "))
}
return fmt.Errorf("no enabled %s WS/HTTP RPC node configured", order.Network)
}
func validateManualEvmPaymentWithClient(ctx context.Context, client evmChainReader, order *mdb.Orders, txHash common.Hash, token *mdb.ChainToken) error {
receipt, err := client.TransactionReceipt(ctx, txHash)
if err != nil {
return fmt.Errorf("fetch transaction receipt: %w", err)
}
if receipt.Status != types.ReceiptStatusSuccessful {
return fmt.Errorf("transaction is not successful")
}
if receipt.BlockNumber == nil {
return fmt.Errorf("transaction receipt missing block number")
}
txHeader, err := client.HeaderByNumber(ctx, receipt.BlockNumber)
if err != nil {
return fmt.Errorf("fetch transaction block header: %w", err)
}
if err = ensureEvmTransactionNotBeforeOrder(txHeader.Time, order); err != nil {
return err
}
if err = ensureEvmConfirmations(ctx, client, order.Network, receipt.BlockNumber); err != nil {
return err
}
contract, err := normalizeEvmAddress(token.ContractAddress)
if err != nil {
return fmt.Errorf("invalid token contract address: %w", err)
}
to, err := normalizeEvmAddress(order.ReceiveAddress)
if err != nil {
return fmt.Errorf("invalid order receive address: %w", err)
}
amountMismatch := false
for _, item := range receipt.Logs {
if item == nil || !strings.EqualFold(item.Address.Hex(), contract.Hex()) {
continue
}
if len(item.Topics) < 3 || item.Topics[0] != erc20TransferEventHash {
continue
}
if !strings.EqualFold(common.BytesToAddress(item.Topics[2].Bytes()).Hex(), to.Hex()) {
continue
}
rawAmount := new(big.Int).SetBytes(item.Data)
if amountMatchesRaw(order.ActualAmount, rawAmount, token.Decimals) {
return nil
}
amountMismatch = true
}
if amountMismatch {
return fmt.Errorf("transaction amount mismatch")
}
return fmt.Errorf("matching token transfer to order address not found")
}
func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClient, error) {
var clients []manualEvmClient
var connectErrors []string
for _, nodeType := range []string{mdb.RpcNodeTypeWs, mdb.RpcNodeTypeHttp} {
node, err := data.SelectRpcNode(network, nodeType)
if err != nil {
return nil, err
}
if node == nil || node.ID == 0 || strings.TrimSpace(node.Url) == "" {
continue
}
rpcURL := strings.TrimSpace(node.Url)
client, err := ethclient.DialContext(ctx, rpcURL)
if err == nil {
clients = append(clients, manualEvmClient{
label: fmt.Sprintf("%s %s", nodeType, rpcURL),
reader: client,
close: client.Close,
})
continue
}
connectErrors = append(connectErrors, fmt.Sprintf("%s %s: %v", nodeType, rpcURL, err))
}
if len(clients) > 0 {
return clients, nil
}
if len(connectErrors) > 0 {
return nil, fmt.Errorf("connect %s RPC failed: %s", network, strings.Join(connectErrors, "; "))
}
return nil, fmt.Errorf("no enabled %s WS/HTTP RPC node configured", network)
}
func ensureEvmTransactionNotBeforeOrder(blockTime uint64, order *mdb.Orders) error {
if order == nil {
return fmt.Errorf("order not found")
}
if int64(blockTime)*1000 < order.CreatedAt.TimestampMilli() {
return fmt.Errorf("transaction predates the order")
}
return nil
}
func ensureEvmConfirmations(ctx context.Context, client evmChainReader, network string, txBlock *big.Int) error {
chain, err := data.GetChainByNetwork(network)
if err != nil {
return err
}
minConfirmations := 1
if chain != nil && chain.MinConfirmations > 0 {
minConfirmations = chain.MinConfirmations
}
header, err := client.HeaderByNumber(ctx, nil)
if err != nil {
return fmt.Errorf("fetch latest block: %w", err)
}
confirmations := new(big.Int).Sub(header.Number, txBlock)
confirmations.Add(confirmations, big.NewInt(1))
if confirmations.Sign() < 0 || confirmations.Cmp(big.NewInt(int64(minConfirmations))) < 0 {
return fmt.Errorf("transaction confirmations %s below required %d", confirmations.String(), minConfirmations)
}
return nil
}
type tronContractParam struct {
TypeURL string `json:"type_url"`
Value json.RawMessage `json:"value"`
}
type manualTronTransaction struct {
TxID string `json:"txID"`
RawData struct {
Contract []struct {
Type string `json:"type"`
Parameter tronContractParam `json:"parameter"`
} `json:"contract"`
Timestamp int64 `json:"timestamp"`
} `json:"raw_data"`
Ret []struct {
ContractRet string `json:"contractRet"`
} `json:"ret"`
}
type manualTronTxInfo struct {
ID string `json:"id"`
BlockNumber int64 `json:"blockNumber"`
BlockTimeStamp int64 `json:"blockTimeStamp"`
ContractResult []string `json:"contractResult"`
Log []manualTronEventLog `json:"log"`
Receipt struct {
Result string `json:"result"`
} `json:"receipt"`
}
type manualTronEventLog struct {
Address string `json:"address"`
Topics []string `json:"topics"`
Data string `json:"data"`
}
type manualTronBlock struct {
BlockHeader struct {
RawData struct {
Number int64 `json:"number"`
} `json:"raw_data"`
} `json:"block_header"`
}
type manualTronTransferContractValue struct {
OwnerAddress string `json:"owner_address"`
ToAddress string `json:"to_address"`
Amount int64 `json:"amount"`
}
func validateManualTronPayment(order *mdb.Orders, txID string) (string, error) {
normalizedTxID, err := normalizeTronTxID(txID)
if err != nil {
return "", err
}
baseURL, apiKey, err := ResolveTronNode()
if err != nil {
return "", err
}
var tx manualTronTransaction
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactionbyid", map[string]interface{}{"value": normalizedTxID}, &tx); err != nil {
return "", fmt.Errorf("fetch tron transaction: %w", err)
}
if strings.TrimSpace(tx.TxID) == "" {
return "", fmt.Errorf("transaction not found")
}
if strings.TrimSpace(tx.TxID) != "" && !strings.EqualFold(strings.TrimSpace(tx.TxID), normalizedTxID) {
return "", fmt.Errorf("transaction id mismatch")
}
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
return "", fmt.Errorf("transaction is not successful: %s", tx.Ret[0].ContractRet)
}
var info manualTronTxInfo
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactioninfobyid", map[string]interface{}{"value": normalizedTxID}, &info); err != nil {
return "", fmt.Errorf("fetch tron transaction info: %w", err)
}
if info.BlockNumber <= 0 {
return "", fmt.Errorf("transaction is not confirmed")
}
if info.Receipt.Result != "" && info.Receipt.Result != "SUCCESS" {
return "", fmt.Errorf("transaction is not successful: %s", info.Receipt.Result)
}
if err = ensureTronConfirmations(baseURL, apiKey, order.Network, info.BlockNumber); err != nil {
return "", err
}
if info.BlockTimeStamp <= 0 {
return "", fmt.Errorf("transaction block timestamp missing")
}
if info.BlockTimeStamp < order.CreatedAt.TimestampMilli() {
return "", fmt.Errorf("transaction predates the order")
}
if strings.EqualFold(strings.TrimSpace(order.Token), "TRX") {
if err = validateManualTronNativeTransfer(order, &tx); err != nil {
return "", err
}
return normalizedTxID, nil
}
if err = validateManualTronTRC20Transfer(order, &tx, &info); err != nil {
return "", err
}
return normalizedTxID, nil
}
func validateManualTronNativeTransfer(order *mdb.Orders, tx *manualTronTransaction) error {
if len(tx.RawData.Contract) == 0 || tx.RawData.Contract[0].Type != "TransferContract" {
return fmt.Errorf("transaction is not a TRX transfer")
}
var val manualTronTransferContractValue
if err := json.Unmarshal(tx.RawData.Contract[0].Parameter.Value, &val); err != nil {
return err
}
toAddr, err := tronHexToAddress(val.ToAddress)
if err != nil {
return fmt.Errorf("invalid TRX recipient address: %w", err)
}
if !strings.EqualFold(toAddr, order.ReceiveAddress) {
return fmt.Errorf("transaction recipient mismatch")
}
if !amountMatchesRaw(order.ActualAmount, big.NewInt(val.Amount), 6) {
return fmt.Errorf("transaction amount mismatch")
}
return nil
}
func validateManualTronTRC20Transfer(order *mdb.Orders, tx *manualTronTransaction, info *manualTronTxInfo) error {
token, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, order.Token)
if err != nil {
return err
}
if token == nil || token.ID == 0 || strings.TrimSpace(token.ContractAddress) == "" {
return fmt.Errorf("enabled token contract not configured for tron/%s", order.Token)
}
if len(tx.RawData.Contract) == 0 || tx.RawData.Contract[0].Type != "TriggerSmartContract" {
return fmt.Errorf("transaction is not a TRC20 transfer")
}
contractHex, err := tronAddressToHex(token.ContractAddress)
if err != nil {
return fmt.Errorf("invalid configured TRC20 contract: %w", err)
}
return validateManualTronTRC20TransferEvent(order, info, contractHex, token.Decimals)
}
func validateManualTronTRC20TransferEvent(order *mdb.Orders, info *manualTronTxInfo, contractHex string, decimals int) error {
if info == nil || len(info.Log) == 0 {
return fmt.Errorf("matching TRC20 transfer event to order address not found")
}
toHex, err := tronAddressToHex(order.ReceiveAddress)
if err != nil {
return fmt.Errorf("invalid order receive address: %w", err)
}
transferTopic := strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x")
amountMismatch := false
for _, event := range info.Log {
eventContractHex, err := normalizeTronAddressHex(event.Address)
if err != nil || eventContractHex != contractHex {
continue
}
if len(event.Topics) < 3 || normalizeEventTopic(event.Topics[0]) != transferTopic {
continue
}
eventToHex, err := tronTopicAddressToHex(event.Topics[2])
if err != nil || eventToHex != toHex {
continue
}
rawAmount, err := tronEventDataAmount(event.Data)
if err != nil {
return fmt.Errorf("invalid TRC20 transfer event amount: %w", err)
}
if amountMatchesRaw(order.ActualAmount, rawAmount, decimals) {
return nil
}
amountMismatch = true
}
if amountMismatch {
return fmt.Errorf("transaction amount mismatch")
}
return fmt.Errorf("matching TRC20 transfer event to order address not found")
}
func ensureTronConfirmations(baseURL, apiKey, network string, txBlock int64) error {
var latest manualTronBlock
if err := tronPostJSON(baseURL, apiKey, "/wallet/getnowblock", map[string]interface{}{}, &latest); err != nil {
return fmt.Errorf("fetch latest tron block: %w", err)
}
chain, err := data.GetChainByNetwork(network)
if err != nil {
return err
}
minConfirmations := 1
if chain != nil && chain.MinConfirmations > 0 {
minConfirmations = chain.MinConfirmations
}
confirmations := latest.BlockHeader.RawData.Number - txBlock + 1
if confirmations < int64(minConfirmations) {
return fmt.Errorf("transaction confirmations %d below required %d", confirmations, minConfirmations)
}
return nil
}
func tronPostJSON(baseURL, apiKey, path string, body interface{}, out interface{}) error {
payload, _ := json.Marshal(body)
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(baseURL, "/")+path, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
req.Header.Set("TRON-PRO-API-KEY", apiKey)
}
client := &http.Client{Timeout: manualVerifyRequestTimeout}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw))
}
return json.Unmarshal(raw, out)
}
func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) {
sig = strings.TrimSpace(sig)
txData, err := SolGetTransaction(sig)
if err != nil {
return "", fmt.Errorf("fetch solana transaction: %w", err)
}
if !gjson.GetBytes(txData, "result").Exists() || gjson.GetBytes(txData, "result").Type == gjson.Null {
return "", fmt.Errorf("transaction not found")
}
if err = ensureSolanaConfirmations(order.Network, sig); err != nil {
return "", err
}
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
if err != nil {
return "", err
}
mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
var nativeSolToken *mdb.ChainToken
for i := range tokens {
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
mint := strings.TrimSpace(tokens[i].ContractAddress)
if sym == "SOL" && mint == "" {
nativeSolToken = &tokens[i]
continue
}
if mint != "" {
mintTokens[mint] = &tokens[i]
}
}
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
amountMismatch := false
for _, instruction := range instructions {
transferInfo, parseErr := ParseTransferInfoFromInstruction(instruction, txData)
if parseErr != nil || transferInfo == nil {
continue
}
if !isTransferToAddress(transferInfo, order.ReceiveAddress) {
continue
}
token, amount := resolveSolTokenAndAmount(transferInfo, mintTokens, nativeSolToken)
if !strings.EqualFold(token, order.Token) {
continue
}
if err = ensureSolanaTransferNotBeforeOrder(transferInfo.BlockTime, order); err != nil {
return "", err
}
if amountMatchesFloat(order.ActualAmount, amount) {
return sig, nil
}
amountMismatch = true
}
if amountMismatch {
return "", fmt.Errorf("transaction amount mismatch")
}
return "", fmt.Errorf("matching solana transfer to order address not found")
}
func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) error {
if order == nil {
return fmt.Errorf("order not found")
}
if blockTime <= 0 {
return fmt.Errorf("transaction block time missing")
}
if blockTime*1000 < order.CreatedAt.TimestampMilli() {
return fmt.Errorf("transaction predates the order")
}
return nil
}
func ensureSolanaConfirmations(network, sig string) error {
chain, err := data.GetChainByNetwork(network)
if err != nil {
return err
}
minConfirmations := 1
if chain != nil && chain.MinConfirmations > 0 {
minConfirmations = chain.MinConfirmations
}
if minConfirmations <= 1 {
return nil
}
body, err := SolRetryClient("getSignatureStatuses", []interface{}{
[]string{sig},
map[string]interface{}{"searchTransactionHistory": true},
})
if err != nil {
return fmt.Errorf("fetch solana signature status: %w", err)
}
status := gjson.GetBytes(body, "result.value.0")
if !status.Exists() || status.Type == gjson.Null {
return fmt.Errorf("transaction status not found")
}
if errValue := status.Get("err"); errValue.Exists() && errValue.Type != gjson.Null {
return fmt.Errorf("transaction is not successful: %s", errValue.Raw)
}
if status.Get("confirmationStatus").String() == "finalized" {
return nil
}
confirmations := status.Get("confirmations").Int()
if confirmations < int64(minConfirmations) {
return fmt.Errorf("transaction confirmations %d below required %d", confirmations, minConfirmations)
}
return nil
}
func amountMatchesRaw(expected float64, rawAmount *big.Int, decimals int) bool {
if rawAmount == nil || rawAmount.Sign() <= 0 {
return false
}
if decimals < 0 {
decimals = 0
}
actual := decimal.NewFromBigInt(rawAmount, -int32(decimals))
return roundedAmount(expected).Equal(actual.Round(int32(data.GetAmountPrecision())))
}
func amountMatchesFloat(expected, actual float64) bool {
return roundedAmount(expected).Equal(roundedAmount(actual))
}
func roundedAmount(amount float64) decimal.Decimal {
precision := data.GetAmountPrecision()
return decimal.NewFromFloat(math.MustParsePrecFloat64(amount, precision)).Round(int32(precision))
}
func tronHexToAddress(hexAddr string) (string, error) {
hexAddr, err := normalizeTronAddressHex(hexAddr)
if err != nil {
return "", err
}
raw, err := hex.DecodeString(hexAddr)
if err != nil {
return "", err
}
return tron.EncodeCheck(raw), nil
}
func tronAddressToHex(addr string) (string, error) {
raw, err := tron.DecodeCheck(strings.TrimSpace(addr))
if err != nil {
return "", err
}
if len(raw) != 21 || raw[0] != tron.PrefixMainnet {
return "", fmt.Errorf("invalid tron address")
}
return strings.ToLower(hex.EncodeToString(raw)), nil
}
func normalizeTronAddressHex(hexAddr string) (string, error) {
hexAddr = normalizeTronHexAddress(hexAddr)
raw, err := hex.DecodeString(hexAddr)
if err != nil {
return "", err
}
switch len(raw) {
case 20:
raw = append([]byte{tron.PrefixMainnet}, raw...)
case 21:
if raw[0] != tron.PrefixMainnet {
return "", fmt.Errorf("invalid tron address prefix")
}
default:
return "", fmt.Errorf("invalid address length: %d", len(raw))
}
return strings.ToLower(hex.EncodeToString(raw)), nil
}
func normalizeTronHexAddress(hexAddr string) string {
hexAddr = strings.TrimSpace(hexAddr)
hexAddr = strings.TrimPrefix(hexAddr, "0x")
hexAddr = strings.TrimPrefix(hexAddr, "0X")
return strings.ToLower(hexAddr)
}
func normalizeEventTopic(value string) string {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "0x")
value = strings.TrimPrefix(value, "0X")
return strings.ToLower(value)
}
func tronTopicAddressToHex(topic string) (string, error) {
topic = normalizeEventTopic(topic)
if len(topic) != 64 {
return "", fmt.Errorf("invalid TRON event address topic length")
}
if _, err := hex.DecodeString(topic); err != nil {
return "", err
}
return normalizeTronAddressHex(topic[24:])
}
func tronEventDataAmount(data string) (*big.Int, error) {
data = normalizeEventTopic(data)
if data == "" {
return nil, fmt.Errorf("empty event data")
}
if len(data)%2 != 0 {
return nil, fmt.Errorf("odd-length event data")
}
raw, err := hex.DecodeString(data)
if err != nil {
return nil, err
}
return new(big.Int).SetBytes(raw), nil
}
func isEvmHash(value string) bool {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "0x")
value = strings.TrimPrefix(value, "0X")
if len(value) != 64 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func canonicalEvmHash(value string) (common.Hash, string, error) {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "0x")
value = strings.TrimPrefix(value, "0X")
if len(value) != 64 {
return common.Hash{}, "", fmt.Errorf("invalid EVM transaction hash")
}
if _, err := hex.DecodeString(value); err != nil {
return common.Hash{}, "", err
}
hash := common.HexToHash("0x" + strings.ToLower(value))
return hash, hash.Hex(), nil
}
func normalizeTronTxID(value string) (string, error) {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "0x")
value = strings.TrimPrefix(value, "0X")
if len(value) != 64 {
return "", fmt.Errorf("invalid TRON transaction id length")
}
if _, err := hex.DecodeString(value); err != nil {
return "", err
}
return strings.ToLower(value), nil
}
func normalizeEvmAddress(value string) (common.Address, error) {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "0x")
value = strings.TrimPrefix(value, "0X")
if len(value) != 40 {
return common.Address{}, fmt.Errorf("invalid address length")
}
if _, err := hex.DecodeString(value); err != nil {
return common.Address{}, err
}
return common.HexToAddress("0x" + value), nil
}
@@ -0,0 +1,431 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/GMWalletApp/epusdt/internal/testutil"
"github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/types"
)
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 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 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)
}
if err = dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkTron,
Symbol: "USDT",
ContractAddress: contractAddress,
Decimals: 6,
Enabled: true,
}).Error; err != nil {
t.Fatalf("create token: %v", err)
}
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()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
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,
}).Error; err != nil {
t.Fatalf("create tron rpc node: %v", err)
}
if err = dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkTron,
Symbol: "USDT",
ContractAddress: contractAddress,
Decimals: 6,
Enabled: true,
}).Error; err != nil {
t.Fatalf("create token: %v", err)
}
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)
}
}
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
}
+4
View File
@@ -268,10 +268,14 @@ func HandleOkPayNotify(form map[string]string, rawFormData string) error {
TradeId: order.TradeId, TradeId: order.TradeId,
BlockTransactionId: payload.OrderID, BlockTransactionId: payload.OrderID,
}) })
processed := err == nil
if err != nil && err != constant.OrderBlockAlreadyProcess { if err != nil && err != constant.OrderBlockAlreadyProcess {
return err return err
} }
if processed {
sendPaymentNotification(order)
}
if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil { if err = data.MarkProviderOrderPaid(payload.UniqueID, order.PayProvider, payload.RawFormData); err != nil {
return err return err
} }
+120
View File
@@ -0,0 +1,120 @@
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 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
}
+4 -4
View File
@@ -167,8 +167,8 @@ func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T
}, nil }, nil
}) })
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil { if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0}}`, "json"); err != nil {
t.Fatalf("set rate.forced_usdt_rate: %v", err) t.Fatalf("set rate.forced_rate_list: %v", err)
} }
if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil { if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil {
t.Fatalf("set rate.api_url: %v", err) t.Fatalf("set rate.api_url: %v", err)
@@ -190,8 +190,8 @@ func TestCreateTransactionFailsWhenRateAPIUnavailableAndForcedSettingIsNotPositi
cleanup := testutil.SetupTestDatabases(t) cleanup := testutil.SetupTestDatabases(t)
defer cleanup() defer cleanup()
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil { if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0}}`, "json"); err != nil {
t.Fatalf("set rate.forced_usdt_rate: %v", err) t.Fatalf("set rate.forced_rate_list: %v", err)
} }
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil { if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
t.Fatalf("clear rate.api_url: %v", err) t.Fatalf("clear rate.api_url: %v", err)
+6 -6
View File
@@ -1,23 +1,23 @@
package service package service
import ( import (
"errors"
"github.com/GMWalletApp/epusdt/config" "github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/response" "github.com/GMWalletApp/epusdt/model/response"
"github.com/GMWalletApp/epusdt/util/constant"
) )
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
} }
+12 -1
View File
@@ -26,6 +26,8 @@ import (
// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction // gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
var gProcessedSignatures sync.Map // sig -> unix timestamp var gProcessedSignatures sync.Map // sig -> unix timestamp
var processSolanaOrder = OrderProcessing
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 +152,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 +216,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 +230,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 +257,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 +274,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())
} }
+147
View File
@@ -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"
) )
@@ -52,6 +59,139 @@ func TestResolveSolanaRpcURLWithRow(t *testing.T) {
} }
} }
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 +218,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)
+4
View File
@@ -257,6 +257,10 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token) log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
return return
} }
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
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())
return
}
req := &request.OrderProcessingRequest{ req := &request.OrderProcessingRequest{
ReceiveAddress: walletAddr, ReceiveAddress: walletAddr,
+65
View File
@@ -144,3 +144,68 @@ func TestTryProcessEvmERC20TransferUsesChainTokenContract(t *testing.T) {
t.Fatalf("runtime lock still exists for trade_id=%q", 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")
if err := dao.Mdb.Create(&mdb.ChainToken{
Network: mdb.NetworkEthereum,
Symbol: tokenSym,
ContractAddress: contract.Hex(),
Decimals: 6,
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(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)
}
}
+348 -1
View File
@@ -2,15 +2,18 @@ package route
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"github.com/GMWalletApp/epusdt/config"
"github.com/GMWalletApp/epusdt/model/dao" "github.com/GMWalletApp/epusdt/model/dao"
"github.com/GMWalletApp/epusdt/model/data" "github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb" "github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/service"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
) )
@@ -320,6 +323,16 @@ func TestAdminInitPasswordFlow(t *testing.T) {
if fetchData["password"] != initPassword { if fetchData["password"] != initPassword {
t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"]) t.Fatalf("expected initial password %s, got %v", initPassword, fetchData["password"])
} }
var plaintextRows int64
if err := dao.Mdb.Unscoped().
Model(&mdb.Setting{}).
Where("`key` = ?", mdb.SettingKeyInitAdminPasswordPlain).
Count(&plaintextRows).Error; err != nil {
t.Fatalf("count init password plaintext rows: %v", err)
}
if plaintextRows != 0 {
t.Fatalf("expected init password plaintext to be hard-deleted, got %d rows", plaintextRows)
}
recFetch2 := doGet(e, "/admin/api/v1/auth/init-password") recFetch2 := doGet(e, "/admin/api/v1/auth/init-password")
if recFetch2.Code != http.StatusBadRequest { if recFetch2.Code != http.StatusBadRequest {
@@ -647,6 +660,138 @@ func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
} }
} }
func TestAdminOrders_MarkPaidSuccessAfterVerification(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-mark-paid-ok",
OrderId: "order-admin-mark-paid-ok",
Amount: 10,
Currency: "CNY",
ActualAmount: 1.23,
ReceiveAddress: "TTestTronAddress001",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/notify",
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
verified := false
restore := service.SetManualOrderPaymentValidatorForTest(func(got *mdb.Orders, blockID string) (string, error) {
verified = true
if got.TradeId != order.TradeId {
t.Fatalf("validator trade_id = %s, want %s", got.TradeId, order.TradeId)
}
if blockID != "block-admin-ok" {
t.Fatalf("validator block id = %s, want block-admin-ok", blockID)
}
return "canonical-block-admin-ok", nil
})
defer restore()
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
"block_transaction_id": "block-admin-ok",
}, token)
t.Logf("MarkOrderPaid success: status=%d body=%s", rec.Code, rec.Body.String())
assertOK(t, rec)
if !verified {
t.Fatal("expected chain verifier to be called")
}
paid, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if paid.Status != mdb.StatusPaySuccess {
t.Fatalf("status = %d, want %d", paid.Status, mdb.StatusPaySuccess)
}
if paid.BlockTransactionId != "canonical-block-admin-ok" {
t.Fatalf("block_transaction_id = %q", paid.BlockTransactionId)
}
if paid.CallBackConfirm != mdb.CallBackConfirmNo {
t.Fatalf("callback_confirm = %d, want %d", paid.CallBackConfirm, mdb.CallBackConfirmNo)
}
}
func TestAdminOrders_MarkPaidRejectsVerificationFailure(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-mark-paid-bad-proof",
OrderId: "order-admin-mark-paid-bad-proof",
Amount: 10,
Currency: "CNY",
ActualAmount: 1.23,
ReceiveAddress: "TTestTronAddress001",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/notify",
PayProvider: mdb.PaymentProviderOnChain,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
return "", errors.New("transaction amount mismatch")
})
defer restore()
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
"block_transaction_id": "block-admin-bad",
}, token)
t.Logf("MarkOrderPaid verifier failure: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
}
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.Status != mdb.StatusWaitPay || reloaded.BlockTransactionId != "" {
t.Fatalf("order changed after failed verification: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
}
}
func TestAdminOrders_MarkPaidRejectsNonOnChainOrder(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-mark-paid-provider",
OrderId: "order-admin-mark-paid-provider",
Amount: 10,
Currency: "CNY",
ActualAmount: 1.23,
ReceiveAddress: "TTestTronAddress001",
Token: "USDT",
Network: mdb.NetworkTron,
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/notify",
PayProvider: mdb.PaymentProviderOkPay,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
called := false
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
called = true
return "block-admin-provider", nil
})
defer restore()
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
"block_transaction_id": "block-admin-provider",
}, token)
t.Logf("MarkOrderPaid provider order: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
}
if called {
t.Fatal("verifier should not run for non-on-chain order")
}
}
// TestAdminOrders_ResendCallbackNotFound verifies resend-callback graceful error. // TestAdminOrders_ResendCallbackNotFound verifies resend-callback graceful error.
func TestAdminOrders_ResendCallbackNotFound(t *testing.T) { func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
@@ -657,6 +802,59 @@ func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
} }
} }
func TestAdminOrders_ResendCallbackRejectsEmptyNotifyURL(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-resend-empty-url",
OrderId: "order-admin-resend-empty-url",
Status: mdb.StatusPaySuccess,
CallBackConfirm: mdb.CallBackConfirmOk,
CallbackNum: 3,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
t.Logf("ResendCallback empty notify_url: status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code == http.StatusOK {
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
}
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.CallBackConfirm != mdb.CallBackConfirmOk || reloaded.CallbackNum != 3 {
t.Fatalf("callback state changed: confirm=%d num=%d", reloaded.CallBackConfirm, reloaded.CallbackNum)
}
}
func TestAdminOrders_ResendCallbackRequeuesPaidOrder(t *testing.T) {
e, token := setupAdminTestEnv(t)
order := &mdb.Orders{
TradeId: "trade-admin-resend-ok",
OrderId: "order-admin-resend-ok",
Status: mdb.StatusPaySuccess,
NotifyUrl: "https://merchant.example/notify",
CallBackConfirm: mdb.CallBackConfirmOk,
CallbackNum: 3,
}
if err := dao.Mdb.Create(order).Error; err != nil {
t.Fatalf("create order: %v", err)
}
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
t.Logf("ResendCallback success: status=%d body=%s", rec.Code, rec.Body.String())
assertOK(t, rec)
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
if err != nil {
t.Fatalf("reload order: %v", err)
}
if reloaded.CallBackConfirm != mdb.CallBackConfirmNo || reloaded.CallbackNum != 0 {
t.Fatalf("callback state = confirm %d num %d, want no/0", reloaded.CallBackConfirm, reloaded.CallbackNum)
}
}
// ─── Dashboard ─────────────────────────────────────────────────────────────── // ─── Dashboard ───────────────────────────────────────────────────────────────
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200. // TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200.
@@ -711,13 +909,64 @@ func TestAdminSettings_ListAndUpsert(t *testing.T) {
// Upsert a setting. // Upsert a setting.
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{ rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{ "items": []map[string]interface{}{
{"group": "rate", "key": "rate.forced_usdt_rate", "value": "7.5", "type": "string"}, {"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
"cny": map[string]interface{}{"usdt": 0.14635},
"usd": map[string]interface{}{"usdt": 1},
}, "type": "json"},
}, },
}, token) }, token)
t.Logf("UpsertSettings: %s", rec.Body.String()) t.Logf("UpsertSettings: %s", rec.Body.String())
assertOK(t, rec) assertOK(t, rec)
} }
func TestAdminSettings_ForcedRateListValidation(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
" CNY ": map[string]interface{}{" USDT ": 0.14635},
}, "type": "string"},
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": `{"cny":`, "type": "json"},
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
"CNY": map[string]interface{}{"USDT": 0.14635},
"cny": map[string]interface{}{"usdt": 0.14636},
}, "type": "json"},
},
}, token)
resp := assertOK(t, rec)
results, ok := resp["data"].([]interface{})
if !ok || len(results) != 3 {
t.Fatalf("expected three results, got %T %v", resp["data"], resp["data"])
}
first, _ := results[0].(map[string]interface{})
if first["ok"] != true {
t.Fatalf("valid forced rate list result = %v", first)
}
second, _ := results[1].(map[string]interface{})
if second["ok"] != false {
t.Fatalf("invalid forced rate list result = %v, want ok=false", second)
}
third, _ := results[2].(map[string]interface{})
if third["ok"] != false {
t.Fatalf("duplicate forced rate list result = %v, want ok=false", third)
}
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, ""); got != `{"cny":{"usdt":0.14635}}` {
t.Fatalf("forced rate list value = %q", got)
}
if rate := config.GetRateForCoin("USDT", "CNY"); rate != 0.14635 {
t.Fatalf("GetRateForCoin(USDT, CNY) = %v, want 0.14635", rate)
}
var row mdb.Setting
if err := dao.Mdb.Where("`key` = ?", mdb.SettingKeyRateForcedRateList).Take(&row).Error; err != nil {
t.Fatalf("load forced rate list: %v", err)
}
if row.Type != mdb.SettingTypeJSON {
t.Fatalf("forced rate list type = %q, want %q", row.Type, mdb.SettingTypeJSON)
}
}
func TestAdminSettings_AmountPrecisionValidationAndListing(t *testing.T) { func TestAdminSettings_AmountPrecisionValidationAndListing(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
@@ -786,6 +1035,104 @@ func TestAdminSettings_DeleteNonExistent(t *testing.T) {
} }
} }
func TestAdminSettings_DeleteThenReupsertRestoresSetting(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://rate.old.example", "type": "string"},
},
}, token)
assertOK(t, rec)
rec = doDeleteAdmin(e, "/admin/api/v1/settings/"+mdb.SettingKeyRateApiUrl, token)
assertOK(t, rec)
if got := data.GetSettingString(mdb.SettingKeyRateApiUrl, "fallback"); got != "fallback" {
t.Fatalf("deleted setting still in cache/read path: got %q", got)
}
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateApiUrl, "value": "https://rate.new.example", "type": "string"},
},
}, token)
assertOK(t, rec)
rec = doGetAdmin(e, "/admin/api/v1/settings?group=rate", token)
resp := assertOK(t, rec)
rows, ok := resp["data"].([]interface{})
if !ok {
t.Fatalf("expected settings array, got %T", resp["data"])
}
found := false
for _, row := range rows {
item, _ := row.(map[string]interface{})
if item["key"] == mdb.SettingKeyRateApiUrl {
found = true
if item["value"] != "https://rate.new.example" {
t.Fatalf("rate.api_url value = %v, want new value", item["value"])
}
}
}
if !found {
t.Fatalf("expected settings list to include restored %s", mdb.SettingKeyRateApiUrl)
}
var restored mdb.Setting
if err := dao.Mdb.Unscoped().Where("`key` = ?", mdb.SettingKeyRateApiUrl).Take(&restored).Error; err != nil {
t.Fatalf("load restored setting unscoped: %v", err)
}
if restored.DeletedAt.Valid {
t.Fatalf("restored setting still has deleted_at=%v", restored.DeletedAt)
}
}
func TestAdminSettings_DeleteThenReupsertRestoresForcedRateList(t *testing.T) {
e, token := setupAdminTestEnv(t)
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
"CNY": map[string]interface{}{"USDT": 0.14635},
}, "type": "json"},
},
}, token)
assertOK(t, rec)
rec = doDeleteAdmin(e, "/admin/api/v1/settings/"+mdb.SettingKeyRateForcedRateList, token)
assertOK(t, rec)
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, "fallback"); got != "fallback" {
t.Fatalf("deleted forced rate list still in cache/read path: got %q", got)
}
if got := config.GetRateForCoin("USDT", "CNY"); got != 0 {
t.Fatalf("deleted forced rate list still used by rate lookup: got %v", got)
}
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
"items": []map[string]interface{}{
{"group": "rate", "key": mdb.SettingKeyRateForcedRateList, "value": map[string]interface{}{
"CNY": map[string]interface{}{"USDT": 0.15},
}, "type": "json"},
},
}, token)
assertOK(t, rec)
if got := data.GetSettingString(mdb.SettingKeyRateForcedRateList, ""); got != `{"cny":{"usdt":0.15}}` {
t.Fatalf("restored forced rate list = %q", got)
}
if got := config.GetRateForCoin("USDT", "CNY"); got != 0.15 {
t.Fatalf("restored forced rate list lookup = %v, want 0.15", got)
}
var restored mdb.Setting
if err := dao.Mdb.Unscoped().Where("`key` = ?", mdb.SettingKeyRateForcedRateList).Take(&restored).Error; err != nil {
t.Fatalf("load restored forced rate list unscoped: %v", err)
}
if restored.DeletedAt.Valid {
t.Fatalf("restored forced rate list still has deleted_at=%v", restored.DeletedAt)
}
}
func TestAdminConfig_ExposesOkPayCredentials(t *testing.T) { func TestAdminConfig_ExposesOkPayCredentials(t *testing.T) {
e, token := setupAdminTestEnv(t) e, token := setupAdminTestEnv(t)
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "admin cashier", mdb.SettingTypeString); err != nil { if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "admin cashier", mdb.SettingTypeString); err != nil {
+183 -38
View File
@@ -20,6 +20,7 @@ import (
"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"
"github.com/dromara/carbon/v2"
"github.com/go-resty/resty/v2" "github.com/go-resty/resty/v2"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/spf13/viper" "github.com/spf13/viper"
@@ -80,8 +81,8 @@ func setupTestEnv(t *testing.T) *echo.Echo {
t.Cleanup(func() { t.Cleanup(func() {
config.SettingsGetString = nil config.SettingsGetString = nil
}) })
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "7.0", "string"); err != nil { if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.14285714285714285}}`, "json"); err != nil {
t.Fatalf("seed rate.forced_usdt_rate: %v", err) t.Fatalf("seed rate.forced_rate_list: %v", err)
} }
// seed wallet addresses // seed wallet addresses
@@ -483,6 +484,11 @@ func TestGetSupportedAssets_WalletAddressToggle(t *testing.T) {
func TestGetPublicConfig(t *testing.T) { func TestGetPublicConfig(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
oldVersion := config.BuildVersion
config.BuildVersion = "v1.0.1"
t.Cleanup(func() {
config.BuildVersion = oldVersion
})
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "asd", mdb.SettingTypeString); err != nil { if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandCheckoutName, "asd", mdb.SettingTypeString); err != nil {
t.Fatalf("seed brand.checkout_name: %v", err) t.Fatalf("seed brand.checkout_name: %v", err)
} }
@@ -526,6 +532,9 @@ func TestGetPublicConfig(t *testing.T) {
if len(supports) < 2 { if len(supports) < 2 {
t.Fatalf("expected >= 2 network supports, got %d", len(supports)) t.Fatalf("expected >= 2 network supports, got %d", len(supports))
} }
if respData["version"] != "v1.0.1" {
t.Fatalf("version = %v, want v1.0.1", respData["version"])
}
site, ok := respData["site"].(map[string]interface{}) site, ok := respData["site"].(map[string]interface{})
if !ok { if !ok {
t.Fatalf("expected site object, got %T", respData["site"]) t.Fatalf("expected site object, got %T", respData["site"])
@@ -578,6 +587,12 @@ func TestGetPublicConfig(t *testing.T) {
row := item.(map[string]interface{}) row := item.(map[string]interface{})
network := row["network"].(string) network := row["network"].(string)
seen[network] = true seen[network] = true
if _, ok := row["display_name"].(string); !ok {
t.Fatalf("supported_assets[%s].display_name missing or not string: %#v", network, row["display_name"])
}
if network == "tron" && row["display_name"] != "TRON" {
t.Fatalf("supported_assets.tron.display_name = %v, want TRON", row["display_name"])
}
} }
for _, n := range []string{"tron", "solana"} { for _, n := range []string{"tron", "solana"} {
if !seen[n] { if !seen[n] {
@@ -989,51 +1004,64 @@ func TestCheckStatus_NotFound(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
e.ServeHTTP(rec, req) e.ServeHTTP(rec, req)
t.Logf("CheckStatus(not found): status=%d body=%s", rec.Code, rec.Body.String()) t.Logf("CheckStatus(not found): status=%d body=%s", rec.Code, rec.Body.String())
if rec.Code >= 500 { if rec.Code != http.StatusBadRequest {
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String()) t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
} }
if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 { var resp map[string]interface{}
t.Fatalf("route returned 404 with empty body — route may not be registered") if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal check-status error response: %v", err)
}
if got := int(resp["status_code"].(float64)); got != 10008 {
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
} }
} }
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns 200 // TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns the
// and a status field when the order exists. // real order status for existing orders in all checkout-relevant states.
func TestCheckStatus_WithOrder(t *testing.T) { func TestCheckStatus_WithOrder(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
// Create an order first via the GMPAY route. cases := []struct {
body := signBody(map[string]interface{}{ name string
"order_id": "check-status-001", status int
"amount": 1.00, }{
"token": "usdt", {name: "wait-pay", status: mdb.StatusWaitPay},
"currency": "cny", {name: "paid", status: mdb.StatusPaySuccess},
"network": "tron", {name: "expired", status: mdb.StatusExpired},
"notify_url": "http://localhost/notify",
})
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
if createRec.Code != http.StatusOK {
t.Fatalf("create order failed: %d %s", createRec.Code, createRec.Body.String())
}
var createResp map[string]interface{}
json.Unmarshal(createRec.Body.Bytes(), &createResp)
tradeId, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
if tradeId == "" {
t.Fatal("no trade_id in create response")
} }
// Now check status. for _, tc := range cases {
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeId, nil) t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder() tradeID := createCheckoutCounterRespTestOrder(t, e, "check-status-"+tc.name)
e.ServeHTTP(rec, req) if err := dao.Mdb.Model(&mdb.Orders{}).
t.Logf("CheckStatus: status=%d body=%s", rec.Code, rec.Body.String()) Where("trade_id = ?", tradeID).
if rec.Code != http.StatusOK { Update("status", tc.status).Error; err != nil {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) t.Fatalf("update status: %v", err)
} }
var resp map[string]interface{}
json.Unmarshal(rec.Body.Bytes(), &resp) req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
if resp["data"] == nil { rec := httptest.NewRecorder()
t.Fatal("expected data in check-status response") e.ServeHTTP(rec, req)
t.Logf("CheckStatus(%s): status=%d body=%s", tc.name, rec.Code, rec.Body.String())
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal check-status response: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected data in check-status response, got: %v", resp)
}
if got, _ := data["trade_id"].(string); got != tradeID {
t.Fatalf("trade_id = %q, want %q", got, tradeID)
}
if got := int(data["status"].(float64)); got != tc.status {
t.Fatalf("status = %d, want %d", got, tc.status)
}
})
} }
} }
@@ -1061,6 +1089,123 @@ func TestCheckoutCounter_NotFound(t *testing.T) {
} }
} }
func TestCheckoutCounterResp_ReturnsPaidOrder(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-paid-001")
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Update("status", mdb.StatusPaySuccess).Error; err != nil {
t.Fatalf("mark order paid: %v", err)
}
data := getCheckoutCounterRespData(t, e, tradeID)
if got, _ := data["trade_id"].(string); got != tradeID {
t.Fatalf("trade_id = %q, want %q; data=%v", got, tradeID, data)
}
if got, _ := data["redirect_url"].(string); got != "https://merchant.example/return" {
t.Fatalf("redirect_url = %q", got)
}
}
func TestCheckoutCounterResp_ReturnsExpiredOrder(t *testing.T) {
e := setupTestEnv(t)
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-expired-001")
expiredCreatedAt := carbon.Now().SubMinutes(config.GetOrderExpirationTime() + 1)
if err := dao.Mdb.Model(&mdb.Orders{}).
Where("trade_id = ?", tradeID).
Updates(map[string]interface{}{
"status": mdb.StatusExpired,
"created_at": expiredCreatedAt,
}).Error; err != nil {
t.Fatalf("mark order expired: %v", err)
}
data := getCheckoutCounterRespData(t, e, tradeID)
if got, _ := data["trade_id"].(string); got != tradeID {
t.Fatalf("trade_id = %q, want %q; data=%v", got, tradeID, data)
}
expirationTime, _ := data["expiration_time"].(float64)
if int64(expirationTime) > carbon.Now().TimestampMilli() {
t.Fatalf("expiration_time = %.0f, want expired timestamp", expirationTime)
}
}
func TestCheckoutCounterResp_UnknownOrderReturnsClearError(t *testing.T) {
e := setupTestEnv(t)
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter-resp/nonexistent-trade-id", nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal checkout counter error response: %v", err)
}
if got := int(resp["status_code"].(float64)); got != 10008 {
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
}
if resp["message"] == "" {
t.Fatalf("expected error message, got: %v", resp)
}
}
func createCheckoutCounterRespTestOrder(t *testing.T, e *echo.Echo, orderID string) string {
t.Helper()
body := signBody(map[string]interface{}{
"order_id": orderID,
"amount": 1.00,
"token": "usdt",
"currency": "cny",
"network": "tron",
"notify_url": "http://localhost/notify",
"redirect_url": "https://merchant.example/return",
})
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
if rec.Code != http.StatusOK {
t.Fatalf("create order failed: %d %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal create response: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected create response data, got: %v", resp)
}
tradeID, _ := data["trade_id"].(string)
if tradeID == "" {
t.Fatalf("missing trade_id in create response: %v", resp)
}
return tradeID
}
func getCheckoutCounterRespData(t *testing.T, e *echo.Echo, tradeID string) map[string]interface{} {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter-resp/"+tradeID, nil)
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
var resp map[string]interface{}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal checkout counter response: %v", err)
}
data, ok := resp["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected checkout counter data, got: %v", resp)
}
return data
}
// TestSwitchNetwork_MissingFields verifies that /pay/switch-network validates // TestSwitchNetwork_MissingFields verifies that /pay/switch-network validates
// required fields and returns a graceful error when they are missing. // required fields and returns a graceful error when they are missing.
func TestSwitchNetwork_MissingFields(t *testing.T) { func TestSwitchNetwork_MissingFields(t *testing.T) {
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./unauthorized-error-BfqXR-z5.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./unauthorized-error-DkFdLxfv.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./forbidden-CxIEu3wC.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./forbidden-BKAmFUZf.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./not-found-error-BtnqPSZ8.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./not-found-error-CZZt4CsO.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./general-error-CX-5x5zG.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./general-error-BPYa4EqL.js";var t=e;export{t as component};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./maintenance-error-iY3svc2d.js";var t=e;export{t as component};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./maintenance-error-Bk0apz-t.js";var t=e;export{t as component};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Cf as n}from"./messages-CA0k74Lg.js";import{o as r,s as i,t as a}from"./useRouter-DtTm7XkK.js";import{t as o}from"./useStore-CupyIEEP.js";import{f as s}from"./ClientOnly-DJrTKhNk.js";import"./redirect-DMNGvjzO.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-CBdZnJ7h.js";import{n as p}from"./matchContext-BFCdcHzo.js";import{t as m}from"./atom-DQpaPCwp.js";function h(e){if(typeof document<`u`&&document.querySelector){let t=e.stores.location.get(),n=t.state.__hashScrollIntoViewOptions??!0;if(n&&t.hash!==``){let e=document.getElementById(t.hash);e&&e.scrollIntoView(n)}}}var g=e(t(),1);function _(){let e=a(),t=g.useRef({router:e,mounted:!1}),[n,c]=g.useState(!1),l=o(e.stores.isLoading,e=>e),u=o(e.stores.hasPending,e=>e),f=i(l),p=l||n||u,_=i(p),v=l||u,y=i(v);return e.startTransition=e=>{c(!0),g.startTransition(()=>{e(),c(!1)})},g.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return s(e.latestLocation.publicHref)!==s(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),r(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),r(()=>{f&&!l&&e.emit({type:`onLoad`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[f,e,l]),r(()=>{y&&!v&&e.emit({type:`onBeforeRouteMount`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[v,y,e]),r(()=>{if(_&&!p){let t=d(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),m(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())}),t.hrefChanged&&h(e)}},[p,_,e]),null}var v=n();function y(){let e=a(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,v.jsx)(t,{}):null,r=(0,v.jsxs)(typeof document<`u`&&e.ssr?u:g.Suspense,{fallback:n,children:[(0,v.jsx)(_,{}),(0,v.jsx)(b,{})]});return e.options.InnerWrap?(0,v.jsx)(e.options.InnerWrap,{children:r}):r}function b(){let e=a(),t=o(e.stores.firstId,e=>e),n=o(e.stores.loadedAt,e=>e),r=t?(0,v.jsx)(f,{matchId:t}):null;return(0,v.jsx)(p.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,v.jsx)(l,{getResetKey:()=>n,errorComponent:c,onCatch:void 0,children:r})})}function x(){let e=a();return o(e.stores.matchRouteDeps,e=>e),g.useCallback(t=>{let{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a,...o}=t;return e.matchRoute(o,{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a})},[e])}export{x as n,y as t};
-1
View File
@@ -1 +0,0 @@
import{af as e,nf as t,tf as n}from"./messages-BvSF842O.js";import{o as r,s as i,t as a}from"./useRouter-DVaO3x1M.js";import{t as o}from"./useStore-C0QnsklM.js";import{f as s}from"./ClientOnly-C2xi1NZw.js";import"./redirect-dE0wk0WH.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-2kn-2UmU.js";import{n as p}from"./matchContext-DY0RaDqI.js";import{t as m}from"./atom-DI1Bn-1s.js";function h(e){if(typeof document<`u`&&document.querySelector){let t=e.stores.location.get(),n=t.state.__hashScrollIntoViewOptions??!0;if(n&&t.hash!==``){let e=document.getElementById(t.hash);e&&e.scrollIntoView(n)}}}var g=e(t(),1);function _(){let e=a(),t=g.useRef({router:e,mounted:!1}),[n,c]=g.useState(!1),l=o(e.stores.isLoading,e=>e),u=o(e.stores.hasPending,e=>e),f=i(l),p=l||n||u,_=i(p),v=l||u,y=i(v);return e.startTransition=e=>{c(!0),g.startTransition(()=>{e(),c(!1)})},g.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return s(e.latestLocation.publicHref)!==s(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),r(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),r(()=>{f&&!l&&e.emit({type:`onLoad`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[f,e,l]),r(()=>{y&&!v&&e.emit({type:`onBeforeRouteMount`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[v,y,e]),r(()=>{if(_&&!p){let t=d(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),m(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())}),t.hrefChanged&&h(e)}},[p,_,e]),null}var v=n();function y(){let e=a(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,v.jsx)(t,{}):null,r=(0,v.jsxs)(typeof document<`u`&&e.ssr?u:g.Suspense,{fallback:n,children:[(0,v.jsx)(_,{}),(0,v.jsx)(b,{})]});return e.options.InnerWrap?(0,v.jsx)(e.options.InnerWrap,{children:r}):r}function b(){let e=a(),t=o(e.stores.firstId,e=>e),n=o(e.stores.loadedAt,e=>e),r=t?(0,v.jsx)(f,{matchId:t}):null;return(0,v.jsx)(p.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,v.jsx)(l,{getResetKey:()=>n,errorComponent:c,onCatch:void 0,children:r})})}function x(){let e=a();return o(e.stores.matchRouteDeps,e=>e),g.useCallback(t=>{let{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a,...o}=t;return e.matchRoute(o,{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a})},[e])}export{x as n,y as t};
@@ -0,0 +1 @@
import{h as e}from"./editor.api2-EJ2HlU1X.js";import"./toggleHighContrast-D5DdjvyL.js";var t={},n={},r=class e{static getOrCreate(t){return n[t]||(n[t]=new e(t)),n[t]}constructor(e){this._languageId=e,this._loadingTriggered=!1,this._lazyLoadPromise=new Promise((e,t)=>{this._lazyLoadPromiseResolve=e,this._lazyLoadPromiseReject=t})}load(){return this._loadingTriggered||(this._loadingTriggered=!0,t[this._languageId].loader().then(e=>this._lazyLoadPromiseResolve(e),e=>this._lazyLoadPromiseReject(e))),this._lazyLoadPromise}};function i(n){let i=n.id;t[i]=n,e.register(n);let a=r.getOrCreate(i);e.registerTokensProviderFactory(i,{create:async()=>(await a.load()).language}),e.onLanguageEncountered(i,async()=>{let t=await a.load();e.setLanguageConfiguration(i,t.conf)})}export{i as t};
+1
View File
@@ -0,0 +1 @@
import{Cf as e}from"./messages-CA0k74Lg.js";import{m as t}from"./search-provider-DWK6TDJZ.js";import{n,t as r}from"./theme-switch-KnQlFw6B.js";import{t as i}from"./general-error-BPYa4EqL.js";import{t as a}from"./not-found-error-CZZt4CsO.js";import{t as o}from"./_error-D4LX2IBE.js";import{t as s}from"./unauthorized-error-DkFdLxfv.js";import{t as c}from"./forbidden-CxIEu3wC.js";import{t as l}from"./maintenance-error-Bk0apz-t.js";import{n as u,r as d,t as f}from"./header-CPV8ardC.js";var p=e();function m(){let{error:e}=o.useParams(),m={unauthorized:s,forbidden:c,"not-found":a,"internal-server-error":i,"maintenance-error":l}[e]||a;return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)(f,{className:`border-b`,fixed:!0,children:[(0,p.jsx)(u,{}),(0,p.jsxs)(`div`,{className:`ms-auto flex items-center space-x-4`,children:[(0,p.jsx)(n,{}),(0,p.jsx)(r,{}),(0,p.jsx)(t,{}),(0,p.jsx)(d,{})]})]}),(0,p.jsx)(`div`,{className:`flex-1 [&>div]:h-full`,children:(0,p.jsx)(m,{})})]})}export{m as component};
-1
View File
@@ -1 +0,0 @@
import{tf as e}from"./messages-BvSF842O.js";import{m as t}from"./search-provider-S_pK7Mew.js";import{n,t as r}from"./theme-switch-LtlARoAw.js";import{t as i}from"./general-error-CX-5x5zG.js";import{t as a}from"./not-found-error-BtnqPSZ8.js";import{t as o}from"./_error-DYN8rorm.js";import{t as s}from"./unauthorized-error-BfqXR-z5.js";import{t as c}from"./forbidden-BKAmFUZf.js";import{t as l}from"./maintenance-error-iY3svc2d.js";import{n as u,r as d,t as f}from"./header-BarMDbkJ.js";var p=e();function m(){let{error:e}=o.useParams(),m={unauthorized:s,forbidden:c,"not-found":a,"internal-server-error":i,"maintenance-error":l}[e]||a;return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)(f,{className:`border-b`,fixed:!0,children:[(0,p.jsx)(u,{}),(0,p.jsxs)(`div`,{className:`ms-auto flex items-center space-x-4`,children:[(0,p.jsx)(n,{}),(0,p.jsx)(r,{}),(0,p.jsx)(t,{}),(0,p.jsx)(d,{})]})]}),(0,p.jsx)(`div`,{className:`flex-1 [&>div]:h-full`,children:(0,p.jsx)(m,{})})]})}export{m as component};
+2
View File
@@ -0,0 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-B9-a0cdE.js","assets/search-provider-DWK6TDJZ.js","assets/chunk-DECur_0Z.js","assets/dist-CRowTfGU.js","assets/dist-D_tdgZ66.js","assets/messages-CA0k74Lg.js","assets/react-CO2uhaBc.js","assets/confirm-dialog-BNVecbxK.js","assets/button-CfqkRSM8.js","assets/clsx-D9aGYY3V.js","assets/dist-BfYQgKqf.js","assets/dist-mJssHgzR.js","assets/dist-SPNRXZQu.js","assets/dist-HEKr0dx0.js","assets/es2015-DuM6Ss-a.js","assets/dist-CPm5TblV.js","assets/dist-XIAMhfzx.js","assets/dist-DPvlEg8c.js","assets/dist-DgKWRoFp.js","assets/dist-BOraPTE4.js","assets/dist-BixeH3nX.js","assets/dist-cJGSCQbF.js","assets/separator-DZMLZkQo.js","assets/tooltip-DcWYpxEL.js","assets/dist-CSGBXlDH.js","assets/dist-puuGF5Ru.js","assets/auth-store-CeX2QaQI.js","assets/dist-BJ92XTI_.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useRouter-DtTm7XkK.js","assets/useNavigate-7u4DX0Ho.js","assets/useStore-CupyIEEP.js","assets/command-BS0F4xWI.js","assets/createLucideIcon-Br0Bd5k2.js","assets/dialog-B3xx2MKo.js","assets/x-BG8b_1Kq.js","assets/skeleton-Dd8Z_2kW.js","assets/wallet-B9YaRua4.js","assets/sun-BwqrnykR.js","assets/shield-check-LJiSka38.js","assets/logo-3EWDGjCa.js","assets/input-Dyq5zXZe.js","assets/font-provider-DWuumz_Z.js","assets/theme-provider-RRpWB-Nr.js","assets/theme-switch-KnQlFw6B.js","assets/dropdown-menu-DzCpLWTE.js","assets/check-DldThLle.js","assets/header-CPV8ardC.js","assets/link-FSeyVZmM.js","assets/ClientOnly-DJrTKhNk.js","assets/forbidden-CxIEu3wC.js","assets/general-error-BPYa4EqL.js","assets/maintenance-error-Bk0apz-t.js","assets/not-found-error-CZZt4CsO.js","assets/unauthorized-error-DkFdLxfv.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-FYLLYRy0.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/_authenticated/errors/$error`)({component:t(()=>n(()=>import(`./_error-B9-a0cdE.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55])),`component`)});export{r as t};
-2
View File
@@ -1,2 +0,0 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-BJEoAZn5.js","assets/messages-BvSF842O.js","assets/search-provider-S_pK7Mew.js","assets/dist-BOyWZkOA.js","assets/dist-gwfT6Lh0.js","assets/confirm-dialog-BINqXK0b.js","assets/button-D5xmeb32.js","assets/dist-CCr-5-sC.js","assets/dist-CCJTittj.js","assets/dist-BbslaEHP.js","assets/dist-BPr298C3.js","assets/es2015-DJhPRjvM.js","assets/dist-BJxEqcEF.js","assets/dist-ibg4sB6V.js","assets/dist-BtKF_Fdt.js","assets/dist-DJOPBXks.js","assets/dist-ClhK3NkD.js","assets/dist-DdarLBQJ.js","assets/separator-S9ojZdzv.js","assets/tooltip-D20upJw9.js","assets/dist-DWza9pO6.js","assets/dist-DNwhj9Co.js","assets/auth-store-2l354uaq.js","assets/dist-C7U751gb.js","assets/with-selector-CWUgF2FI.js","assets/cookies-C6BqVyqT.js","assets/useRouter-DVaO3x1M.js","assets/useNavigate-DGiKRiQU.js","assets/useStore-C0QnsklM.js","assets/command-NHXN_saF.js","assets/createLucideIcon-BGQWeu8F.js","assets/x-CJvS5f0_.js","assets/skeleton-CbqiOymc.js","assets/wallet-BEnVSmzH.js","assets/sun-eqHSs_DP.js","assets/shield-check-ncnxEJs4.js","assets/logo-BmsPR42p.js","assets/input-CPAfWtuT.js","assets/font-provider-3vIeNUj6.js","assets/theme-provider-DOdLvAnl.js","assets/theme-switch-LtlARoAw.js","assets/dropdown-menu-Cvv2xK8F.js","assets/check-Bv7wmlvP.js","assets/header-BarMDbkJ.js","assets/link-CeK9z2o_.js","assets/ClientOnly-C2xi1NZw.js","assets/forbidden-BKAmFUZf.js","assets/general-error-CX-5x5zG.js","assets/maintenance-error-iY3svc2d.js","assets/not-found-error-BtnqPSZ8.js","assets/unauthorized-error-BfqXR-z5.js"])))=>i.map(i=>d[i]);
import{n as e,r as t,t as n}from"./preload-helper-Co27Ga7q.js";var r=t(`/_authenticated/errors/$error`)({component:e(()=>n(()=>import(`./_error-BJEoAZn5.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50])),`component`)});export{r as t};
-2
View File
@@ -1,2 +0,0 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-Fw6UGEEp.js","assets/messages-BvSF842O.js","assets/button-D5xmeb32.js","assets/select-BvqNeB11.js","assets/dist-DWza9pO6.js","assets/dist-gwfT6Lh0.js","assets/dist-BbslaEHP.js","assets/dist-BPr298C3.js","assets/dist-DdarLBQJ.js","assets/dist-BOyWZkOA.js","assets/dist-DJOPBXks.js","assets/dist-ibg4sB6V.js","assets/dist-CCJTittj.js","assets/es2015-DJhPRjvM.js","assets/dist-ClhK3NkD.js","assets/dist-DNwhj9Co.js","assets/createLucideIcon-BGQWeu8F.js","assets/check-Bv7wmlvP.js","assets/chevron-down-DXILDlcC.js","assets/auth-store-2l354uaq.js","assets/dist-C7U751gb.js","assets/with-selector-CWUgF2FI.js","assets/cookies-C6BqVyqT.js","assets/useNavigate-DGiKRiQU.js","assets/useRouter-DVaO3x1M.js","assets/copy-to-clipboard-CgiNCkz-.js","assets/arrow-left-Bnh1rsc-.js","assets/radio-group-un6EA-ec.js","assets/dist-BtKF_Fdt.js","assets/dist-BJxEqcEF.js","assets/loader-circle-Duqd1vVQ.js","assets/triangle-alert-CEMQD8Oa.js","assets/x-CJvS5f0_.js","assets/checkout-model-CxAw66kk.js"])))=>i.map(i=>d[i]);
import{n as e,r as t,t as n}from"./preload-helper-Co27Ga7q.js";var r=t(`/cashier/$trade_id/`)({component:e(()=>n(()=>import(`./_trade_id-Fw6UGEEp.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33])),`component`)});export{r as t};
+2
View File
@@ -0,0 +1,2 @@
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-Q3vN9SK7.js","assets/chunk-DECur_0Z.js","assets/button-CfqkRSM8.js","assets/clsx-D9aGYY3V.js","assets/messages-CA0k74Lg.js","assets/react-CO2uhaBc.js","assets/select-0mb7fNAV.js","assets/dist-CSGBXlDH.js","assets/dist-D_tdgZ66.js","assets/dist-SPNRXZQu.js","assets/dist-HEKr0dx0.js","assets/dist-cJGSCQbF.js","assets/dist-CRowTfGU.js","assets/dist-BOraPTE4.js","assets/dist-XIAMhfzx.js","assets/dist-mJssHgzR.js","assets/es2015-DuM6Ss-a.js","assets/dist-BixeH3nX.js","assets/dist-puuGF5Ru.js","assets/createLucideIcon-Br0Bd5k2.js","assets/check-DldThLle.js","assets/chevron-down-BPPKEraq.js","assets/auth-store-CeX2QaQI.js","assets/dist-BJ92XTI_.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useNavigate-7u4DX0Ho.js","assets/useRouter-DtTm7XkK.js","assets/copy-to-clipboard--eZunBzP.js","assets/arrow-left-Ck1MERx0.js","assets/radio-group-DkPZzBqR.js","assets/dist-DPvlEg8c.js","assets/dist-CPm5TblV.js","assets/dist-DgKWRoFp.js","assets/loader-circle-DRj7MiMc.js","assets/triangle-alert-D6KmQYVs.js","assets/x-BG8b_1Kq.js","assets/labels-CdV8bTde.js","assets/checkout-model-BfGeSuFW.js"])))=>i.map(i=>d[i]);
import{n as e,t}from"./lazyRouteComponent-FYLLYRy0.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/cashier/$trade_id/`)({component:t(()=>n(()=>import(`./_trade_id-Q3vN9SK7.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38])),`component`)});export{r as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{Ud as e,Wd as t,an as n,cn as r,dn as i,ln as a,on as o,sn as s,tf as c,un as l}from"./messages-BvSF842O.js";import{i as u,n as d,t as f}from"./button-D5xmeb32.js";import{n as p,r as m}from"./font-provider-3vIeNUj6.js";import{n as h}from"./theme-provider-DOdLvAnl.js";import{t as g}from"./chevron-down-DXILDlcC.js";import{n as _,t as v}from"./radio-group-un6EA-ec.js";import{r as y,s as b}from"./zod-ClGKjEyB.js";import{a as x,c as S,i as C,l as w,n as T,o as E,r as D,s as O,t as k}from"./form-iIuz3zSX.js";import{t as A}from"./page-header-D58MnYJU.js";import{t as j}from"./show-submitted-data-Bl1Wpk1q.js";var M=c(),N=b({theme:y([`light`,`dark`]),font:y(m)});function P(){let{font:i,setFont:c}=p(),{theme:l,setTheme:y}=h(),b={theme:l,font:i},A=w({resolver:S(N),defaultValues:b});function P(e){e.font!==i&&c(e.font),e.theme!==l&&y(e.theme),j(e)}return(0,M.jsx)(k,{...A,children:(0,M.jsxs)(`form`,{className:`space-y-8`,onSubmit:A.handleSubmit(P),children:[(0,M.jsx)(C,{control:A.control,name:`font`,render:({field:e})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:a()}),(0,M.jsxs)(`div`,{className:`relative w-max`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(`select`,{className:u(d({variant:`outline`}),`w-50 appearance-none font-normal capitalize`,`dark:bg-background dark:hover:bg-background`),...e,children:m.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))})}),(0,M.jsx)(g,{className:`absolute inset-e-3 top-2.5 h-4 w-4 opacity-50`})]}),(0,M.jsx)(D,{className:`font-manrope`,children:r()}),(0,M.jsx)(O,{})]})}),(0,M.jsx)(C,{control:A.control,name:`theme`,render:({field:n})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:s()}),(0,M.jsx)(D,{children:o()}),(0,M.jsx)(O,{}),(0,M.jsxs)(v,{className:`grid max-w-md grid-cols-2 gap-8 pt-2`,defaultValue:n.value,onValueChange:n.onChange,children:[(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`light`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted p-1 hover:border-accent`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-[#ecedef] p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:t()})]})}),(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`dark`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-slate-950 p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:e()})]})})]})]})}),(0,M.jsx)(f,{type:`submit`,children:n()})]})})}function F(){return(0,M.jsx)(A,{description:l(),title:i(),variant:`section`,children:(0,M.jsx)(P,{})})}var I=F;export{I as component}; import{Cf as e,cn as t,df as n,dn as r,ff as i,fn as a,ln as o,on as s,sn as c,un as l}from"./messages-CA0k74Lg.js";import{i as u,n as d,t as f}from"./button-CfqkRSM8.js";import{n as p,r as m}from"./font-provider-DWuumz_Z.js";import{n as h}from"./theme-provider-RRpWB-Nr.js";import{t as g}from"./chevron-down-BPPKEraq.js";import{n as _,t as v}from"./radio-group-DkPZzBqR.js";import{r as y,s as b}from"./zod-CtXLSkFW.js";import{a as x,c as S,i as C,l as w,n as T,o as E,r as D,s as O,t as k}from"./form-C3PRruHl.js";import{t as A}from"./page-header-DLBUeYaU.js";import{t as j}from"./show-submitted-data-Idyx5pnC.js";var M=e(),N=b({theme:y([`light`,`dark`]),font:y(m)});function P(){let{font:e,setFont:r}=p(),{theme:a,setTheme:y}=h(),b={theme:a,font:e},A=w({resolver:S(N),defaultValues:b});function P(t){t.font!==e&&r(t.font),t.theme!==a&&y(t.theme),j(t)}return(0,M.jsx)(k,{...A,children:(0,M.jsxs)(`form`,{className:`space-y-8`,onSubmit:A.handleSubmit(P),children:[(0,M.jsx)(C,{control:A.control,name:`font`,render:({field:e})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:l()}),(0,M.jsxs)(`div`,{className:`relative w-max`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(`select`,{className:u(d({variant:`outline`}),`w-50 appearance-none font-normal capitalize`,`dark:bg-background dark:hover:bg-background`),...e,children:m.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))})}),(0,M.jsx)(g,{className:`absolute inset-e-3 top-2.5 h-4 w-4 opacity-50`})]}),(0,M.jsx)(D,{className:`font-manrope`,children:o()}),(0,M.jsx)(O,{})]})}),(0,M.jsx)(C,{control:A.control,name:`theme`,render:({field:e})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:t()}),(0,M.jsx)(D,{children:c()}),(0,M.jsx)(O,{}),(0,M.jsxs)(v,{className:`grid max-w-md grid-cols-2 gap-8 pt-2`,defaultValue:e.value,onValueChange:e.onChange,children:[(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`light`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted p-1 hover:border-accent`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-[#ecedef] p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:i()})]})}),(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`dark`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-slate-950 p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:n()})]})})]})]})}),(0,M.jsx)(f,{type:`submit`,children:s()})]})})}function F(){return(0,M.jsx)(A,{description:r(),title:a(),variant:`section`,children:(0,M.jsx)(P,{})})}var I=F;export{I as component};
@@ -1 +1 @@
import{t as e}from"./createLucideIcon-BGQWeu8F.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t}; import{t as e}from"./createLucideIcon-Br0Bd5k2.js";var t=e(`arrow-left`,[[`path`,{d:`m12 19-7-7 7-7`,key:`1l729n`}],[`path`,{d:`M19 12H5`,key:`x3x0zl`}]]);export{t};
@@ -0,0 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Cf as n}from"./messages-CA0k74Lg.js";var r=e(t(),1),i=n(),a=(0,r.createContext)({isTyping:!1,passwordLength:0,setIsTyping:()=>void 0,setPasswordLength:()=>void 0,setShowPassword:()=>void 0,showPassword:!1});function o({children:e}){let[t,n]=(0,r.useState)(!1),[o,s]=(0,r.useState)(!1),[c,l]=(0,r.useState)(0);return(0,i.jsx)(a.Provider,{value:{isTyping:t,passwordLength:c,setIsTyping:n,setPasswordLength:l,setShowPassword:s,showPassword:o},children:e})}function s(){return(0,r.useContext)(a)}export{s as n,o as t};
@@ -1 +0,0 @@
import{af as e,nf as t,tf as n}from"./messages-BvSF842O.js";var r=e(t(),1),i=n(),a=(0,r.createContext)({isTyping:!1,passwordLength:0,setIsTyping:()=>void 0,setPasswordLength:()=>void 0,setShowPassword:()=>void 0,showPassword:!1});function o({children:e}){let[t,n]=(0,r.useState)(!1),[o,s]=(0,r.useState)(!1),[c,l]=(0,r.useState)(0);return(0,i.jsx)(a.Provider,{value:{isTyping:t,passwordLength:c,setIsTyping:n,setPasswordLength:l,setShowPassword:s,showPassword:o},children:e})}function s(){return(0,r.useContext)(a)}export{s as n,o as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{Cf as e}from"./messages-CA0k74Lg.js";import{i as t,o as n,r}from"./button-CfqkRSM8.js";var i=e(),a=r(`inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3`,{variants:{variant:{default:`bg-primary text-primary-foreground [a&]:hover:bg-primary/90`,secondary:`bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90`,destructive:`bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90`,outline:`border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,ghost:`[a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,link:`text-primary underline-offset-4 [a&]:hover:underline`}},defaultVariants:{variant:`default`}});function o({className:e,variant:r=`default`,asChild:o=!1,...s}){return(0,i.jsx)(o?n:`span`,{className:t(a({variant:r}),e),"data-slot":`badge`,"data-variant":r,...s})}export{o as t};
-1
View File
@@ -1 +0,0 @@
import{tf as e}from"./messages-BvSF842O.js";import{i as t,r as n,s as r}from"./button-D5xmeb32.js";var i=e(),a=n(`inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3`,{variants:{variant:{default:`bg-primary text-primary-foreground [a&]:hover:bg-primary/90`,secondary:`bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90`,destructive:`bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90`,outline:`border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,ghost:`[a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,link:`text-primary underline-offset-4 [a&]:hover:underline`}},defaultVariants:{variant:`default`}});function o({className:e,variant:n=`default`,asChild:o=!1,...s}){return(0,i.jsx)(o?r:`span`,{className:t(a({variant:n}),e),"data-slot":`badge`,"data-variant":n,...s})}export{o as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
import{tf as e}from"./messages-BvSF842O.js";import{i as t}from"./button-D5xmeb32.js";var n=e();function r({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm`,e),"data-slot":`card`,...r})}function i({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6`,e),"data-slot":`card-header`,...r})}function a({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`font-semibold leading-none`,e),"data-slot":`card-title`,...r})}function o({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`text-muted-foreground text-sm`,e),"data-slot":`card-description`,...r})}function s({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`px-6`,e),"data-slot":`card-content`,...r})}export{a,i,s as n,o as r,r as t}; import{Cf as e}from"./messages-CA0k74Lg.js";import{i as t}from"./button-CfqkRSM8.js";var n=e();function r({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm`,e),"data-slot":`card`,...r})}function i({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6`,e),"data-slot":`card-header`,...r})}function a({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`font-semibold leading-none`,e),"data-slot":`card-title`,...r})}function o({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`text-muted-foreground text-sm`,e),"data-slot":`card-description`,...r})}function s({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`px-6`,e),"data-slot":`card-content`,...r})}export{a,i,s as n,o as r,r as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{a as t,d as n,u as r}from"./auth-store-CeX2QaQI.js";import{t as i}from"./react-CO2uhaBc.js";import{t as a}from"./createLucideIcon-Br0Bd5k2.js";var o=a(`pen-line`,[[`path`,{d:`M13 21h8`,key:`1jsn5i`}],[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}]]),s=e(i(),1),c=new r({chains:[],loading:!1});function l(e){c.setState(t=>({...t,chains:e}))}function u(e){c.setState(t=>({...t,loading:e}))}function d(){let e=n(c,e=>e.chains),r=n(c,e=>e.loading),i=t.useQuery(`get`,`/admin/api/v1/chains`);return(0,s.useEffect)(()=>{u(i.isLoading)},[i.isLoading]),(0,s.useEffect)(()=>{i.data?.data&&l(i.data.data)},[i.data?.data]),{chains:e,loading:r,refetch:i.refetch}}export{o as n,d as t};
-1
View File
@@ -1 +0,0 @@
import{af as e,nf as t}from"./messages-BvSF842O.js";import{a as n,d as r,u as i}from"./auth-store-2l354uaq.js";import{t as a}from"./createLucideIcon-BGQWeu8F.js";var o=a(`pen-line`,[[`path`,{d:`M13 21h8`,key:`1jsn5i`}],[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}]]),s=e(t(),1),c=new i({chains:[],loading:!1});function l(e){c.setState(t=>({...t,chains:e}))}function u(e){c.setState(t=>({...t,loading:e}))}function d(){let e=r(c,e=>e.chains),t=r(c,e=>e.loading),i=n.useQuery(`get`,`/admin/api/v1/chains`);return(0,s.useEffect)(()=>{u(i.isLoading)},[i.isLoading]),(0,s.useEffect)(()=>{i.data?.data&&l(i.data.data)},[i.data?.data]),{chains:e,loading:t,refetch:i.refetch}}export{o as n,d as t};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./createLucideIcon-BGQWeu8F.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]);export{t};
+1
View File
@@ -0,0 +1 @@
import{t as e}from"./createLucideIcon-Br0Bd5k2.js";var t=e(`check`,[[`path`,{d:`M20 6 9 17l-5-5`,key:`1gmf2c`}]]);export{t};
+1
View File
@@ -0,0 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Cf as n}from"./messages-CA0k74Lg.js";import{t as r}from"./dist-HEKr0dx0.js";import{i,u as a}from"./button-CfqkRSM8.js";import{a as o,r as s,t as c}from"./dist-D_tdgZ66.js";import{t as l}from"./dist-CPm5TblV.js";import{t as u}from"./dist-BixeH3nX.js";import{t as d}from"./dist-cJGSCQbF.js";import{t as f}from"./check-DldThLle.js";var p=e(t(),1),m=n(),h=`Checkbox`,[g,_]=o(h),[v,y]=g(h);function b(e){let{__scopeCheckbox:t,checked:n,children:r,defaultChecked:i,disabled:a,form:o,name:s,onCheckedChange:l,required:u,value:d=`on`,internal_do_not_use_render:f}=e,[g,_]=c({prop:n,defaultProp:i??!1,onChange:l,caller:h}),[y,b]=p.useState(null),[x,S]=p.useState(null),C=p.useRef(!1),w=y?!!o||!!y.closest(`form`):!0,T={checked:g,disabled:a,setChecked:_,control:y,setControl:b,name:s,form:o,value:d,hasConsumerStoppedPropagationRef:C,required:u,defaultChecked:k(i)?!1:i,isFormControl:w,bubbleInput:x,setBubbleInput:S};return(0,m.jsx)(v,{scope:t,...T,children:O(f)?f(T):r})}var x=`CheckboxTrigger`,S=p.forwardRef(({__scopeCheckbox:e,onKeyDown:t,onClick:n,...i},o)=>{let{control:c,value:l,disabled:u,checked:d,required:f,setControl:h,setChecked:g,hasConsumerStoppedPropagationRef:_,isFormControl:v,bubbleInput:b}=y(x,e),S=a(o,h),C=p.useRef(d);return p.useEffect(()=>{let e=c?.form;if(e){let t=()=>g(C.current);return e.addEventListener(`reset`,t),()=>e.removeEventListener(`reset`,t)}},[c,g]),(0,m.jsx)(r.button,{type:`button`,role:`checkbox`,"aria-checked":k(d)?`mixed`:d,"aria-required":f,"data-state":A(d),"data-disabled":u?``:void 0,disabled:u,value:l,...i,ref:S,onKeyDown:s(t,e=>{e.key===`Enter`&&e.preventDefault()}),onClick:s(n,e=>{g(e=>k(e)?!0:!e),b&&v&&(_.current=e.isPropagationStopped(),_.current||e.stopPropagation())})})});S.displayName=x;var C=p.forwardRef((e,t)=>{let{__scopeCheckbox:n,name:r,checked:i,defaultChecked:a,required:o,disabled:s,value:c,onCheckedChange:l,form:u,...d}=e;return(0,m.jsx)(b,{__scopeCheckbox:n,checked:i,defaultChecked:a,disabled:s,required:o,onCheckedChange:l,name:r,form:u,value:c,internal_do_not_use_render:({isFormControl:e})=>(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(S,{...d,ref:t,__scopeCheckbox:n}),e&&(0,m.jsx)(D,{__scopeCheckbox:n})]})})});C.displayName=h;var w=`CheckboxIndicator`,T=p.forwardRef((e,t)=>{let{__scopeCheckbox:n,forceMount:i,...a}=e,o=y(w,n);return(0,m.jsx)(l,{present:i||k(o.checked)||o.checked===!0,children:(0,m.jsx)(r.span,{"data-state":A(o.checked),"data-disabled":o.disabled?``:void 0,...a,ref:t,style:{pointerEvents:`none`,...e.style}})})});T.displayName=w;var E=`CheckboxBubbleInput`,D=p.forwardRef(({__scopeCheckbox:e,...t},n)=>{let{control:i,hasConsumerStoppedPropagationRef:o,checked:s,defaultChecked:c,required:l,disabled:f,name:h,value:g,form:_,bubbleInput:v,setBubbleInput:b}=y(E,e),x=a(n,b),S=u(s),C=d(i);p.useEffect(()=>{let e=v;if(!e)return;let t=window.HTMLInputElement.prototype,n=Object.getOwnPropertyDescriptor(t,`checked`).set,r=!o.current;if(S!==s&&n){let t=new Event(`click`,{bubbles:r});e.indeterminate=k(s),n.call(e,k(s)?!1:s),e.dispatchEvent(t)}},[v,S,s,o]);let w=p.useRef(k(s)?!1:s);return(0,m.jsx)(r.input,{type:`checkbox`,"aria-hidden":!0,defaultChecked:c??w.current,required:l,disabled:f,name:h,value:g,form:_,...t,tabIndex:-1,ref:x,style:{...t.style,...C,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0,transform:`translateX(-100%)`}})});D.displayName=E;function O(e){return typeof e==`function`}function k(e){return e===`indeterminate`}function A(e){return k(e)?`indeterminate`:e?`checked`:`unchecked`}function j({className:e,...t}){return(0,m.jsx)(C,{className:i(`peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary dark:aria-invalid:ring-destructive/40`,e),"data-slot":`checkbox`,...t,children:(0,m.jsx)(T,{className:`grid place-content-center text-current transition-none`,"data-slot":`checkbox-indicator`,children:(0,m.jsx)(f,{className:`size-3.5`})})})}export{j as t};
-1
View File
@@ -1 +0,0 @@
import{af as e,nf as t,tf as n}from"./messages-BvSF842O.js";import{t as r}from"./dist-BPr298C3.js";import{d as i,i as a}from"./button-D5xmeb32.js";import{a as o,r as s,t as c}from"./dist-gwfT6Lh0.js";import{t as l}from"./dist-BJxEqcEF.js";import{t as u}from"./dist-ClhK3NkD.js";import{t as d}from"./dist-DdarLBQJ.js";import{t as f}from"./check-Bv7wmlvP.js";var p=e(t(),1),m=n(),h=`Checkbox`,[g,_]=o(h),[v,y]=g(h);function b(e){let{__scopeCheckbox:t,checked:n,children:r,defaultChecked:i,disabled:a,form:o,name:s,onCheckedChange:l,required:u,value:d=`on`,internal_do_not_use_render:f}=e,[g,_]=c({prop:n,defaultProp:i??!1,onChange:l,caller:h}),[y,b]=p.useState(null),[x,S]=p.useState(null),C=p.useRef(!1),w=y?!!o||!!y.closest(`form`):!0,T={checked:g,disabled:a,setChecked:_,control:y,setControl:b,name:s,form:o,value:d,hasConsumerStoppedPropagationRef:C,required:u,defaultChecked:k(i)?!1:i,isFormControl:w,bubbleInput:x,setBubbleInput:S};return(0,m.jsx)(v,{scope:t,...T,children:O(f)?f(T):r})}var x=`CheckboxTrigger`,S=p.forwardRef(({__scopeCheckbox:e,onKeyDown:t,onClick:n,...a},o)=>{let{control:c,value:l,disabled:u,checked:d,required:f,setControl:h,setChecked:g,hasConsumerStoppedPropagationRef:_,isFormControl:v,bubbleInput:b}=y(x,e),S=i(o,h),C=p.useRef(d);return p.useEffect(()=>{let e=c?.form;if(e){let t=()=>g(C.current);return e.addEventListener(`reset`,t),()=>e.removeEventListener(`reset`,t)}},[c,g]),(0,m.jsx)(r.button,{type:`button`,role:`checkbox`,"aria-checked":k(d)?`mixed`:d,"aria-required":f,"data-state":A(d),"data-disabled":u?``:void 0,disabled:u,value:l,...a,ref:S,onKeyDown:s(t,e=>{e.key===`Enter`&&e.preventDefault()}),onClick:s(n,e=>{g(e=>k(e)?!0:!e),b&&v&&(_.current=e.isPropagationStopped(),_.current||e.stopPropagation())})})});S.displayName=x;var C=p.forwardRef((e,t)=>{let{__scopeCheckbox:n,name:r,checked:i,defaultChecked:a,required:o,disabled:s,value:c,onCheckedChange:l,form:u,...d}=e;return(0,m.jsx)(b,{__scopeCheckbox:n,checked:i,defaultChecked:a,disabled:s,required:o,onCheckedChange:l,name:r,form:u,value:c,internal_do_not_use_render:({isFormControl:e})=>(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(S,{...d,ref:t,__scopeCheckbox:n}),e&&(0,m.jsx)(D,{__scopeCheckbox:n})]})})});C.displayName=h;var w=`CheckboxIndicator`,T=p.forwardRef((e,t)=>{let{__scopeCheckbox:n,forceMount:i,...a}=e,o=y(w,n);return(0,m.jsx)(l,{present:i||k(o.checked)||o.checked===!0,children:(0,m.jsx)(r.span,{"data-state":A(o.checked),"data-disabled":o.disabled?``:void 0,...a,ref:t,style:{pointerEvents:`none`,...e.style}})})});T.displayName=w;var E=`CheckboxBubbleInput`,D=p.forwardRef(({__scopeCheckbox:e,...t},n)=>{let{control:a,hasConsumerStoppedPropagationRef:o,checked:s,defaultChecked:c,required:l,disabled:f,name:h,value:g,form:_,bubbleInput:v,setBubbleInput:b}=y(E,e),x=i(n,b),S=u(s),C=d(a);p.useEffect(()=>{let e=v;if(!e)return;let t=window.HTMLInputElement.prototype,n=Object.getOwnPropertyDescriptor(t,`checked`).set,r=!o.current;if(S!==s&&n){let t=new Event(`click`,{bubbles:r});e.indeterminate=k(s),n.call(e,k(s)?!1:s),e.dispatchEvent(t)}},[v,S,s,o]);let w=p.useRef(k(s)?!1:s);return(0,m.jsx)(r.input,{type:`checkbox`,"aria-hidden":!0,defaultChecked:c??w.current,required:l,disabled:f,name:h,value:g,form:_,...t,tabIndex:-1,ref:x,style:{...t.style,...C,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0,transform:`translateX(-100%)`}})});D.displayName=E;function O(e){return typeof e==`function`}function k(e){return e===`indeterminate`}function A(e){return k(e)?`indeterminate`:e?`checked`:`unchecked`}function j({className:e,...t}){return(0,m.jsx)(C,{className:a(`peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary dark:aria-invalid:ring-destructive/40`,e),"data-slot":`checkbox`,...t,children:(0,m.jsx)(T,{className:`grid place-content-center text-current transition-none`,"data-slot":`checkbox-indicator`,children:(0,m.jsx)(f,{className:`size-3.5`})})})}export{j as t};
@@ -1 +1 @@
var e=5e3,t=3e3,n=[10008,10013,404],r=10010,i=`Request failed`;function a(e){return{trade_id:e.trade_id,amount:e.amount,actual_amount:e.actual_amount,token:e.token?.toUpperCase(),currency:e.currency?.toUpperCase(),network:e.network,payment_url:e.payment_url,receive_address:e.receive_address,expiration_time:e.expiration_time,redirect_url:e.redirect_url,created_at:e.created_at,is_selected:e.is_selected}}function o(e){if(e!=null)return typeof e==`object`&&`data`in e?e.data:e}function s(e){return typeof e==`boolean`?e:typeof e==`number`?e!==0:[`1`,`true`,`yes`].includes(String(e??``).toLowerCase())}function c(e){if(e&&typeof e==`object`){if(`status_code`in e&&typeof e.status_code==`number`)return e.status_code;if(`code`in e&&typeof e.code==`number`)return e.code;if(`data`in e)return c(e.data)}}function l(e){if(e==null||e===``)return null;if(typeof e==`number`||/^\d+$/.test(String(e))){let t=Number(e);return new Date(t<0xe8d4a51000?t*1e3:t)}let t=new Date(String(e));return Number.isNaN(t.getTime())?null:t}function u(e,t){return String(e??``).toLowerCase()===String(t??``).toLowerCase()}function d(e){let t=Math.max(0,Math.min(1,e));return t>=.5?`#22c55e`:t>=.2?`#f97316`:`#ef4444`}function f(e,t,n){return e.length===0?null:e.find(e=>u(e.network,n?.network)&&u(e.token,n?.token))||e.find(e=>u(e.network,n?.network))||e.find(e=>u(e.token,n?.token))||e.find(e=>u(e.network,t?.network)&&u(e.token,t?.token))||(e.find(e=>u(e.network,t?.network))??e.find(e=>u(e.token,t?.token))??e[0])}function p(e){return e?.supported_assets?.flatMap(e=>(e.tokens??[]).filter(t=>!!(t&&e.network)).map(t=>({network:e.network,token:t.toUpperCase()})))??[]}function m(e,t){return new Promise((n,r)=>{let i=window.setTimeout(n,e);t?.addEventListener(`abort`,()=>{window.clearTimeout(i),r(t.reason)},{once:!0})})}export{t as a,c,l as d,u as f,o as h,e as i,s as l,p as m,i as n,m as o,d as p,n as r,f as s,r as t,a as u}; var e=5e3,t=3e3,n=[10008,10013,404],r=10010,i=`Request failed`;function a(e){return{trade_id:e.trade_id,amount:e.amount,actual_amount:e.actual_amount,token:e.token?.toUpperCase(),currency:e.currency?.toUpperCase(),network:e.network,payment_url:e.payment_url,receive_address:e.receive_address,expiration_time:e.expiration_time,redirect_url:e.redirect_url,created_at:e.created_at,is_selected:e.is_selected}}function o(e){if(e!=null)return typeof e==`object`&&`data`in e?e.data:e}function s(e){return typeof e==`boolean`?e:typeof e==`number`?e!==0:[`1`,`true`,`yes`].includes(String(e??``).toLowerCase())}function c(e){if(e&&typeof e==`object`){if(`status_code`in e&&typeof e.status_code==`number`)return e.status_code;if(`code`in e&&typeof e.code==`number`)return e.code;if(`data`in e)return c(e.data)}}function l(e){if(e==null||e===``)return null;if(typeof e==`number`||/^\d+$/.test(String(e))){let t=Number(e);return new Date(t<0xe8d4a51000?t*1e3:t)}let t=new Date(String(e));return Number.isNaN(t.getTime())?null:t}function u(e,t){return String(e??``).toLowerCase()===String(t??``).toLowerCase()}function d(e){let t=Math.max(0,Math.min(1,e));return t>=.5?`#22c55e`:t>=.2?`#f97316`:`#ef4444`}function f(e,t,n){return e.length===0?null:e.find(e=>u(e.network,n?.network)&&u(e.token,n?.token))||e.find(e=>u(e.network,n?.network))||e.find(e=>u(e.token,n?.token))||e.find(e=>u(e.network,t?.network)&&u(e.token,t?.token))||(e.find(e=>u(e.network,t?.network))??e.find(e=>u(e.token,t?.token))??e[0])}function p(e){return e?.supported_assets?.flatMap(e=>(e.tokens??[]).filter(t=>!!(t&&e.network)).map(t=>({network:e.network,token:t.toUpperCase(),displayName:e.display_name})))??[]}function m(e,t){return new Promise((n,r)=>{let i=window.setTimeout(n,e);t?.addEventListener(`abort`,()=>{window.clearTimeout(i),r(t.reason)},{once:!0})})}export{t as a,c,l as d,u as f,o as h,e as i,s as l,p as m,i as n,m as o,d as p,n as r,f as s,r as t,a as u};
@@ -1 +1 @@
import{t as e}from"./createLucideIcon-BGQWeu8F.js";var t=e(`chevron-down`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]);export{t}; import{t as e}from"./createLucideIcon-Br0Bd5k2.js";var t=e(`chevron-down`,[[`path`,{d:`m6 9 6 6 6-6`,key:`qrunsl`}]]);export{t};
@@ -1 +1 @@
import{t as e}from"./createLucideIcon-BGQWeu8F.js";var t=e(`chevrons-up-down`,[[`path`,{d:`m7 15 5 5 5-5`,key:`1hf1tw`}],[`path`,{d:`m7 9 5-5 5 5`,key:`sgt6xg`}]]);export{t}; import{t as e}from"./createLucideIcon-Br0Bd5k2.js";var t=e(`chevrons-up-down`,[[`path`,{d:`m7 15 5 5 5-5`,key:`1hf1tw`}],[`path`,{d:`m7 9 5-5 5 5`,key:`sgt6xg`}]]);export{t};
+1
View File
@@ -0,0 +1 @@
var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));export{s as n,l as r,o as t};
-1
View File
@@ -1 +0,0 @@
import{t as e}from"./createLucideIcon-BGQWeu8F.js";var t=e(`clock-3`,[[`circle`,{cx:`12`,cy:`12`,r:`10`,key:`1mglay`}],[`path`,{d:`M12 6v6h4`,key:`135r8i`}]]);export{t};
+1
View File
@@ -0,0 +1 @@
function e(t){var n,r,i=``;if(typeof t==`string`||typeof t==`number`)i+=t;else if(typeof t==`object`)if(Array.isArray(t)){var a=t.length;for(n=0;n<a;n++)t[n]&&(r=e(t[n]))&&(i&&(i+=` `),i+=r)}else for(r in t)t[r]&&(i&&(i+=` `),i+=r);return i}function t(){for(var t,n,r=0,i=``,a=arguments.length;r<a;r++)(t=arguments[r])&&(n=e(t))&&(i&&(i+=` `),i+=n);return i}export{t};
Binary file not shown.
@@ -0,0 +1 @@
import{Cf as e,af as t,if as n,of as r}from"./messages-CA0k74Lg.js";import{t as i}from"./telescope-BCXFXZDp.js";import{a,n as o,o as s,r as c,t as l}from"./dialog-B3xx2MKo.js";var u=e();function d({open:e,onOpenChange:d}){return(0,u.jsx)(l,{onOpenChange:d,open:e,children:(0,u.jsx)(o,{className:`sm:max-w-sm`,children:(0,u.jsxs)(a,{className:`items-center text-center`,children:[(0,u.jsx)(i,{className:`mb-2 size-12 text-muted-foreground`}),(0,u.jsx)(s,{children:r()}),(0,u.jsxs)(c,{children:[t(),(0,u.jsx)(`br`,{}),(0,u.jsx)(`p`,{className:`mt-2 text-center`,children:n()})]})]})})})}export{d as t};
@@ -1 +0,0 @@
import{Id as e,Ld as t,Rd as n,tf as r}from"./messages-BvSF842O.js";import{d as i,l as a,m as o,p as s,u as c}from"./command-NHXN_saF.js";import{t as l}from"./telescope-DmBD-aeq.js";var u=r();function d({open:r,onOpenChange:d}){return(0,u.jsx)(a,{onOpenChange:d,open:r,children:(0,u.jsx)(c,{className:`sm:max-w-sm`,children:(0,u.jsxs)(s,{className:`items-center text-center`,children:[(0,u.jsx)(l,{className:`mb-2 size-12 text-muted-foreground`}),(0,u.jsx)(o,{children:n()}),(0,u.jsxs)(i,{children:[t(),(0,u.jsx)(`br`,{}),(0,u.jsx)(`p`,{className:`mt-2 text-center`,children:e()})]})]})})})}export{d as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
import{Xd as e,Yd as t,af as n,nf as r,tf as i}from"./messages-BvSF842O.js";import{d as a,i as o,l as s,t as c}from"./button-D5xmeb32.js";import{a as l,r as u}from"./dist-gwfT6Lh0.js";import{a as d,c as f,i as p,l as m,n as h,o as g,r as _,s as ee,t as v,u as y}from"./dist-CCr-5-sC.js";var b=n(r(),1),x=i(),S=`AlertDialog`,[C,te]=l(S,[y]),w=y(),T=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(g,{...r,...n,modal:!0})};T.displayName=S;var E=`AlertDialogTrigger`,D=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(f,{...i,...r,ref:t})});D.displayName=E;var O=`AlertDialogPortal`,k=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(d,{...r,...n})};k.displayName=O;var A=`AlertDialogOverlay`,j=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(p,{...i,...r,ref:t})});j.displayName=A;var M=`AlertDialogContent`,[N,P]=C(M),F=s(`AlertDialogContent`),I=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,children:r,...i}=e,o=w(n),s=b.useRef(null),c=a(t,s),l=b.useRef(null);return(0,x.jsx)(m,{contentName:M,titleName:L,docsSlug:`alert-dialog`,children:(0,x.jsx)(N,{scope:n,cancelRef:l,children:(0,x.jsxs)(h,{role:`alertdialog`,...o,...i,ref:c,onOpenAutoFocus:u(i.onOpenAutoFocus,e=>{e.preventDefault(),l.current?.focus({preventScroll:!0})}),onPointerDownOutside:e=>e.preventDefault(),onInteractOutside:e=>e.preventDefault(),children:[(0,x.jsx)(F,{children:r}),(0,x.jsx)(G,{contentRef:s})]})})})});I.displayName=M;var L=`AlertDialogTitle`,R=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(ee,{...i,...r,ref:t})});R.displayName=L;var z=`AlertDialogDescription`,B=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(_,{...i,...r,ref:t})});B.displayName=z;var V=`AlertDialogAction`,H=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(v,{...i,...r,ref:t})});H.displayName=V;var U=`AlertDialogCancel`,W=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,{cancelRef:i}=P(U,n),o=w(n),s=a(t,i);return(0,x.jsx)(v,{...o,...r,ref:s})});W.displayName=U;var G=({contentRef:e})=>{let t=`\`${M}\` requires a description for the component to be accessible for screen reader users. import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Cf as n,_f as r,vf as i}from"./messages-CA0k74Lg.js";import{c as a,i as o,t as s,u as c}from"./button-CfqkRSM8.js";import{a as l,r as u}from"./dist-D_tdgZ66.js";import{a as d,c as f,i as p,l as m,n as h,o as g,r as _,s as ee,t as v,u as y}from"./dist-BfYQgKqf.js";var b=e(t(),1),x=n(),S=`AlertDialog`,[C,te]=l(S,[y]),w=y(),T=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(g,{...r,...n,modal:!0})};T.displayName=S;var E=`AlertDialogTrigger`,D=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(f,{...i,...r,ref:t})});D.displayName=E;var O=`AlertDialogPortal`,k=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(d,{...r,...n})};k.displayName=O;var A=`AlertDialogOverlay`,j=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(p,{...i,...r,ref:t})});j.displayName=A;var M=`AlertDialogContent`,[N,P]=C(M),F=a(`AlertDialogContent`),I=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,children:r,...i}=e,a=w(n),o=b.useRef(null),s=c(t,o),l=b.useRef(null);return(0,x.jsx)(m,{contentName:M,titleName:L,docsSlug:`alert-dialog`,children:(0,x.jsx)(N,{scope:n,cancelRef:l,children:(0,x.jsxs)(h,{role:`alertdialog`,...a,...i,ref:s,onOpenAutoFocus:u(i.onOpenAutoFocus,e=>{e.preventDefault(),l.current?.focus({preventScroll:!0})}),onPointerDownOutside:e=>e.preventDefault(),onInteractOutside:e=>e.preventDefault(),children:[(0,x.jsx)(F,{children:r}),(0,x.jsx)(G,{contentRef:o})]})})})});I.displayName=M;var L=`AlertDialogTitle`,R=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(ee,{...i,...r,ref:t})});R.displayName=L;var z=`AlertDialogDescription`,B=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(_,{...i,...r,ref:t})});B.displayName=z;var V=`AlertDialogAction`,H=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(v,{...i,...r,ref:t})});H.displayName=V;var U=`AlertDialogCancel`,W=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,{cancelRef:i}=P(U,n),a=w(n),o=c(t,i);return(0,x.jsx)(v,{...a,...r,ref:o})});W.displayName=U;var G=({contentRef:e})=>{let t=`\`${M}\` requires a description for the component to be accessible for screen reader users.
You can add a description to the \`${M}\` by passing a \`${z}\` component as a child, which also benefits sighted users by adding visible context to the dialog. You can add a description to the \`${M}\` by passing a \`${z}\` component as a child, which also benefits sighted users by adding visible context to the dialog.
Alternatively, you can use your own component as a description by assigning it an \`id\` and passing the same value to the \`aria-describedby\` prop in \`${M}\`. If the description is confusing or duplicative for sighted users, you can use the \`@radix-ui/react-visually-hidden\` primitive as a wrapper around your description component. Alternatively, you can use your own component as a description by assigning it an \`id\` and passing the same value to the \`aria-describedby\` prop in \`${M}\`. If the description is confusing or duplicative for sighted users, you can use the \`@radix-ui/react-visually-hidden\` primitive as a wrapper around your description component.
For more information, see https://radix-ui.com/primitives/docs/components/alert-dialog`;return b.useEffect(()=>{document.getElementById(e.current?.getAttribute(`aria-describedby`))||console.warn(t)},[t,e]),null},K=T,q=k,J=j,Y=I,X=W,Z=R,Q=B;function ne({...e}){return(0,x.jsx)(K,{"data-slot":`alert-dialog`,...e})}function re({...e}){return(0,x.jsx)(q,{"data-slot":`alert-dialog-portal`,...e})}function ie({className:e,...t}){return(0,x.jsx)(J,{className:o(`data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in`,e),"data-slot":`alert-dialog-overlay`,...t})}function ae({className:e,size:t=`default`,...n}){return(0,x.jsxs)(re,{children:[(0,x.jsx)(ie,{}),(0,x.jsx)(Y,{className:o(`group/alert-dialog-content data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=open]:animate-in data-[size=default]:sm:max-w-lg`,e),"data-size":t,"data-slot":`alert-dialog-content`,...n})]})}function oe({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]`,e),"data-slot":`alert-dialog-header`,...t})}function $({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end`,e),"data-slot":`alert-dialog-footer`,...t})}function se({className:e,...t}){return(0,x.jsx)(Z,{className:o(`font-semibold text-lg sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2`,e),"data-slot":`alert-dialog-title`,...t})}function ce({className:e,...t}){return(0,x.jsx)(Q,{className:o(`text-muted-foreground text-sm`,e),"data-slot":`alert-dialog-description`,...t})}function le({className:e,variant:t=`outline`,size:n=`default`,...r}){return(0,x.jsx)(c,{asChild:!0,size:n,variant:t,children:(0,x.jsx)(X,{className:o(e),"data-slot":`alert-dialog-cancel`,...r})})}function ue(n){let{title:r,desc:i,children:a,className:s,confirmText:l,cancelBtnText:u,destructive:d,isLoading:f,disabled:p=!1,form:m,handleConfirm:h,...g}=n;return(0,x.jsx)(ne,{...g,children:(0,x.jsxs)(ae,{className:o(s&&s),children:[(0,x.jsxs)(oe,{className:`text-start`,children:[(0,x.jsx)(se,{children:r}),(0,x.jsx)(ce,{asChild:!0,children:(0,x.jsx)(`div`,{children:i})})]}),a,(0,x.jsxs)($,{children:[(0,x.jsx)(le,{disabled:f,children:u??e()}),(0,x.jsx)(c,{disabled:p||f,form:m,onClick:h,type:m?`submit`:`button`,variant:d?`destructive`:`default`,children:l??t()})]})]})})}export{ue as t}; For more information, see https://radix-ui.com/primitives/docs/components/alert-dialog`;return b.useEffect(()=>{document.getElementById(e.current?.getAttribute(`aria-describedby`))||console.warn(t)},[t,e]),null},K=T,q=k,J=j,Y=I,X=W,Z=R,Q=B;function ne({...e}){return(0,x.jsx)(K,{"data-slot":`alert-dialog`,...e})}function re({...e}){return(0,x.jsx)(q,{"data-slot":`alert-dialog-portal`,...e})}function ie({className:e,...t}){return(0,x.jsx)(J,{className:o(`data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in`,e),"data-slot":`alert-dialog-overlay`,...t})}function ae({className:e,size:t=`default`,...n}){return(0,x.jsxs)(re,{children:[(0,x.jsx)(ie,{}),(0,x.jsx)(Y,{className:o(`group/alert-dialog-content data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=open]:animate-in data-[size=default]:sm:max-w-lg`,e),"data-size":t,"data-slot":`alert-dialog-content`,...n})]})}function oe({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]`,e),"data-slot":`alert-dialog-header`,...t})}function $({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end`,e),"data-slot":`alert-dialog-footer`,...t})}function se({className:e,...t}){return(0,x.jsx)(Z,{className:o(`font-semibold text-lg sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2`,e),"data-slot":`alert-dialog-title`,...t})}function ce({className:e,...t}){return(0,x.jsx)(Q,{className:o(`text-muted-foreground text-sm`,e),"data-slot":`alert-dialog-description`,...t})}function le({className:e,variant:t=`outline`,size:n=`default`,...r}){return(0,x.jsx)(s,{asChild:!0,size:n,variant:t,children:(0,x.jsx)(X,{className:o(e),"data-slot":`alert-dialog-cancel`,...r})})}function ue(e){let{title:t,desc:n,children:a,className:c,confirmText:l,cancelBtnText:u,destructive:d,isLoading:f,disabled:p=!1,form:m,handleConfirm:h,...g}=e;return(0,x.jsx)(ne,{...g,children:(0,x.jsxs)(ae,{className:o(c&&c),children:[(0,x.jsxs)(oe,{className:`text-start`,children:[(0,x.jsx)(se,{children:t}),(0,x.jsx)(ce,{asChild:!0,children:(0,x.jsx)(`div`,{children:n})})]}),a,(0,x.jsxs)($,{children:[(0,x.jsx)(le,{disabled:f,children:u??i()}),(0,x.jsx)(s,{disabled:p||f,form:m,onClick:h,type:m?`submit`:`button`,variant:d?`destructive`:`default`,children:l??r()})]})]})})}export{ue as t};
@@ -0,0 +1 @@
import{t as e}from"./chunk-DECur_0Z.js";import{t}from"./createLucideIcon-Br0Bd5k2.js";var n=t(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]),r=e(((e,t)=>{t.exports=function(){var e=document.getSelection();if(!e.rangeCount)return function(){};for(var t=document.activeElement,n=[],r=0;r<e.rangeCount;r++)n.push(e.getRangeAt(r));switch(t.tagName.toUpperCase()){case`INPUT`:case`TEXTAREA`:t.blur();break;default:t=null;break}return e.removeAllRanges(),function(){e.type===`Caret`&&e.removeAllRanges(),e.rangeCount||n.forEach(function(t){e.addRange(t)}),t&&t.focus()}}})),i=e(((e,t)=>{var n=r(),i={"text/plain":`Text`,"text/html":`Url`,default:`Text`},a=`Copy to clipboard: #{key}, Enter`;function o(e){var t=(/mac os x/i.test(navigator.userAgent)?``:`Ctrl`)+`+C`;return e.replace(/#{\s*key\s*}/g,t)}function s(e,t){var r,s,c,l,u,d,f=!1;t||={},r=t.debug||!1;try{if(c=n(),l=document.createRange(),u=document.getSelection(),d=document.createElement(`span`),d.textContent=e,d.ariaHidden=`true`,d.style.all=`unset`,d.style.position=`fixed`,d.style.top=0,d.style.clip=`rect(0, 0, 0, 0)`,d.style.whiteSpace=`pre`,d.style.webkitUserSelect=`text`,d.style.MozUserSelect=`text`,d.style.msUserSelect=`text`,d.style.userSelect=`text`,d.addEventListener(`copy`,function(n){if(n.stopPropagation(),t.format)if(n.preventDefault(),n.clipboardData===void 0){r&&console.warn(`unable to use e.clipboardData`),r&&console.warn(`trying IE specific stuff`),window.clipboardData.clearData();var a=i[t.format]||i.default;window.clipboardData.setData(a,e)}else n.clipboardData.clearData(),n.clipboardData.setData(t.format,e);t.onCopy&&(n.preventDefault(),t.onCopy(n.clipboardData))}),document.body.appendChild(d),l.selectNodeContents(d),u.addRange(l),!document.execCommand(`copy`))throw Error(`copy command was unsuccessful`);f=!0}catch(n){r&&console.error(`unable to copy using execCommand: `,n),r&&console.warn(`trying IE specific stuff`);try{window.clipboardData.setData(t.format||`text`,e),t.onCopy&&t.onCopy(window.clipboardData),f=!0}catch(n){r&&console.error(`unable to copy using clipboardData: `,n),r&&console.error(`falling back to prompt`),s=o(`message`in t?t.message:a),window.prompt(s,e)}}finally{u&&(typeof u.removeRange==`function`?u.removeRange(l):u.removeAllRanges()),d&&document.body.removeChild(d),c()}return f}t.exports=s}));export{n,i as t};
@@ -1 +0,0 @@
import{rf as e}from"./messages-BvSF842O.js";import{t}from"./createLucideIcon-BGQWeu8F.js";var n=t(`copy`,[[`rect`,{width:`14`,height:`14`,x:`8`,y:`8`,rx:`2`,ry:`2`,key:`17jyea`}],[`path`,{d:`M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2`,key:`zix9uf`}]]),r=e(((e,t)=>{t.exports=function(){var e=document.getSelection();if(!e.rangeCount)return function(){};for(var t=document.activeElement,n=[],r=0;r<e.rangeCount;r++)n.push(e.getRangeAt(r));switch(t.tagName.toUpperCase()){case`INPUT`:case`TEXTAREA`:t.blur();break;default:t=null;break}return e.removeAllRanges(),function(){e.type===`Caret`&&e.removeAllRanges(),e.rangeCount||n.forEach(function(t){e.addRange(t)}),t&&t.focus()}}})),i=e(((e,t)=>{var n=r(),i={"text/plain":`Text`,"text/html":`Url`,default:`Text`},a=`Copy to clipboard: #{key}, Enter`;function o(e){var t=(/mac os x/i.test(navigator.userAgent)?``:`Ctrl`)+`+C`;return e.replace(/#{\s*key\s*}/g,t)}function s(e,t){var r,s,c,l,u,d,f=!1;t||={},r=t.debug||!1;try{if(c=n(),l=document.createRange(),u=document.getSelection(),d=document.createElement(`span`),d.textContent=e,d.ariaHidden=`true`,d.style.all=`unset`,d.style.position=`fixed`,d.style.top=0,d.style.clip=`rect(0, 0, 0, 0)`,d.style.whiteSpace=`pre`,d.style.webkitUserSelect=`text`,d.style.MozUserSelect=`text`,d.style.msUserSelect=`text`,d.style.userSelect=`text`,d.addEventListener(`copy`,function(n){if(n.stopPropagation(),t.format)if(n.preventDefault(),n.clipboardData===void 0){r&&console.warn(`unable to use e.clipboardData`),r&&console.warn(`trying IE specific stuff`),window.clipboardData.clearData();var a=i[t.format]||i.default;window.clipboardData.setData(a,e)}else n.clipboardData.clearData(),n.clipboardData.setData(t.format,e);t.onCopy&&(n.preventDefault(),t.onCopy(n.clipboardData))}),document.body.appendChild(d),l.selectNodeContents(d),u.addRange(l),!document.execCommand(`copy`))throw Error(`copy command was unsuccessful`);f=!0}catch(n){r&&console.error(`unable to copy using execCommand: `,n),r&&console.warn(`trying IE specific stuff`);try{window.clipboardData.setData(t.format||`text`,e),t.onCopy&&t.onCopy(window.clipboardData),f=!0}catch(n){r&&console.error(`unable to copy using clipboardData: `,n),r&&console.error(`falling back to prompt`),s=o(`message`in t?t.message:a),window.prompt(s,e)}}finally{u&&(typeof u.removeRange==`function`?u.removeRange(l):u.removeAllRanges()),d&&document.body.removeChild(d),c()}return f}t.exports=s}));export{n,i as t};
@@ -1 +0,0 @@
import{af as e,nf as t}from"./messages-BvSF842O.js";var n=e(t()),r=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),i=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),a=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),o=e=>{let t=a(e);return t.charAt(0).toUpperCase()+t.slice(1)},s={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},c=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},l=(0,n.createContext)({}),u=()=>(0,n.useContext)(l),d=(0,n.forwardRef)(({color:e,size:t,strokeWidth:i,absoluteStrokeWidth:a,className:o=``,children:l,iconNode:d,...f},p)=>{let{size:m=24,strokeWidth:h=2,absoluteStrokeWidth:g=!1,color:_=`currentColor`,className:v=``}=u()??{},y=a??g?Number(i??h)*24/Number(t??m):i??h;return(0,n.createElement)(`svg`,{ref:p,...s,width:t??m??s.width,height:t??m??s.height,stroke:e??_,strokeWidth:y,className:r(`lucide`,v,o),...!l&&!c(f)&&{"aria-hidden":`true`},...f},[...d.map(([e,t])=>(0,n.createElement)(e,t)),...Array.isArray(l)?l:[l]])}),f=(e,t)=>{let a=(0,n.forwardRef)(({className:a,...s},c)=>(0,n.createElement)(d,{ref:c,iconNode:t,className:r(`lucide-${i(o(e))}`,`lucide-${e}`,a),...s}));return a.displayName=o(e),a};export{f as t};
@@ -0,0 +1 @@
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";var n=e(t()),r=(...e)=>e.filter((e,t,n)=>!!e&&e.trim()!==``&&n.indexOf(e)===t).join(` `).trim(),i=e=>e.replace(/([a-z0-9])([A-Z])/g,`$1-$2`).toLowerCase(),a=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(e,t,n)=>n?n.toUpperCase():t.toLowerCase()),o=e=>{let t=a(e);return t.charAt(0).toUpperCase()+t.slice(1)},s={xmlns:`http://www.w3.org/2000/svg`,width:24,height:24,viewBox:`0 0 24 24`,fill:`none`,stroke:`currentColor`,strokeWidth:2,strokeLinecap:`round`,strokeLinejoin:`round`},c=e=>{for(let t in e)if(t.startsWith(`aria-`)||t===`role`||t===`title`)return!0;return!1},l=(0,n.createContext)({}),u=()=>(0,n.useContext)(l),d=(0,n.forwardRef)(({color:e,size:t,strokeWidth:i,absoluteStrokeWidth:a,className:o=``,children:l,iconNode:d,...f},p)=>{let{size:m=24,strokeWidth:h=2,absoluteStrokeWidth:g=!1,color:_=`currentColor`,className:v=``}=u()??{},y=a??g?Number(i??h)*24/Number(t??m):i??h;return(0,n.createElement)(`svg`,{ref:p,...s,width:t??m??s.width,height:t??m??s.height,stroke:e??_,strokeWidth:y,className:r(`lucide`,v,o),...!l&&!c(f)&&{"aria-hidden":`true`},...f},[...d.map(([e,t])=>(0,n.createElement)(e,t)),...Array.isArray(l)?l:[l]])}),f=(e,t)=>{let a=(0,n.forwardRef)(({className:a,...s},c)=>(0,n.createElement)(d,{ref:c,iconNode:t,className:r(`lucide-${i(o(e))}`,`lucide-${e}`,a),...s}));return a.displayName=o(e),a};export{f as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

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