mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75dfeced01 | |||
| bfdf453e3c | |||
| 55cc6a9c61 | |||
| 3ab3659fe4 | |||
| 04c2b9b0b6 | |||
| 9ac9894dba | |||
| 9b397bd45e |
@@ -0,0 +1,101 @@
|
|||||||
|
name: sync-www
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
paths-ignore:
|
||||||
|
- "src/www/**"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: sync-www-${{ github.ref_name }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-sync:
|
||||||
|
name: Build Frontend And Sync src/www
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
env:
|
||||||
|
FRONTEND_REPOSITORY: ${{ vars.WWW_FRONTEND_REPOSITORY }}
|
||||||
|
FRONTEND_REF: ${{ vars.WWW_FRONTEND_REF }}
|
||||||
|
FRONTEND_BUILD_DIR: ${{ vars.WWW_FRONTEND_BUILD_DIR }}
|
||||||
|
FRONTEND_TOKEN: ${{ secrets.WWW_FRONTEND_TOKEN }}
|
||||||
|
WWW_TARGET_DIR: src/www
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Backend
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.ref_name }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Validate Configuration
|
||||||
|
run: |
|
||||||
|
if [[ -z "$FRONTEND_REPOSITORY" ]]; then
|
||||||
|
echo "Set repository variable WWW_FRONTEND_REPOSITORY, for example owner/repo"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$FRONTEND_REF" ]]; then
|
||||||
|
echo "Set repository variable WWW_FRONTEND_REF, for example main"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$FRONTEND_BUILD_DIR" ]]; then
|
||||||
|
echo "Set repository variable WWW_FRONTEND_BUILD_DIR, for example dist"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ -z "$FRONTEND_TOKEN" ]]; then
|
||||||
|
echo "Set repository secret WWW_FRONTEND_TOKEN to read the private frontend repository"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Clone Frontend
|
||||||
|
run: |
|
||||||
|
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
||||||
|
frontend_path="$RUNNER_TEMP/$frontend_name"
|
||||||
|
git clone \
|
||||||
|
--depth=1 \
|
||||||
|
--branch "$FRONTEND_REF" \
|
||||||
|
"https://x-access-token:${FRONTEND_TOKEN}@github.com/${FRONTEND_REPOSITORY}.git" \
|
||||||
|
"$frontend_path"
|
||||||
|
|
||||||
|
- name: Set Up Bun
|
||||||
|
uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- name: Build Frontend
|
||||||
|
run: |
|
||||||
|
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
||||||
|
cd "$RUNNER_TEMP/$frontend_name"
|
||||||
|
bun install
|
||||||
|
bun run build
|
||||||
|
|
||||||
|
- name: Replace src/www
|
||||||
|
run: |
|
||||||
|
frontend_name="${FRONTEND_REPOSITORY##*/}"
|
||||||
|
cd "$RUNNER_TEMP/$frontend_name"
|
||||||
|
if [[ ! -d "$FRONTEND_BUILD_DIR" ]]; then
|
||||||
|
echo "Frontend build output does not exist: $FRONTEND_BUILD_DIR"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
target_path="$GITHUB_WORKSPACE/$WWW_TARGET_DIR"
|
||||||
|
mkdir -p "$target_path"
|
||||||
|
rsync -a --delete "$FRONTEND_BUILD_DIR"/ "$target_path"/
|
||||||
|
|
||||||
|
- name: Commit And Push
|
||||||
|
run: |
|
||||||
|
if [[ -z "$(git status --porcelain -- "$WWW_TARGET_DIR")" ]]; then
|
||||||
|
echo "src/www is already up to date"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add "$WWW_TARGET_DIR"
|
||||||
|
git commit -m "chore(www): sync frontend build"
|
||||||
|
git pull --rebase origin "$GITHUB_REF_NAME"
|
||||||
|
git push origin "HEAD:$GITHUB_REF_NAME"
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/mq"
|
"github.com/GMWalletApp/epusdt/mq"
|
||||||
"github.com/GMWalletApp/epusdt/task"
|
"github.com/GMWalletApp/epusdt/task"
|
||||||
"github.com/GMWalletApp/epusdt/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
@@ -21,6 +22,13 @@ func InitApp() {
|
|||||||
config.Init()
|
config.Init()
|
||||||
log.Init()
|
log.Init()
|
||||||
dao.Init()
|
dao.Init()
|
||||||
|
logLevel := data.GetSettingString(mdb.SettingKeySystemLogLevel, mdb.SettingDefaultSystemLogLevel)
|
||||||
|
if err := log.SetLevel(logLevel); err != nil {
|
||||||
|
color.Red.Printf("[bootstrap] apply log level setting %q err=%s; fallback=%s\n", logLevel, err, mdb.SettingDefaultSystemLogLevel)
|
||||||
|
if fallbackErr := log.SetLevel(mdb.SettingDefaultSystemLogLevel); fallbackErr != nil {
|
||||||
|
color.Red.Printf("[bootstrap] apply fallback log level err=%s\n", fallbackErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Wire settings-table lookups into the config package so
|
// Wire settings-table lookups into the config package so
|
||||||
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
|
// GetRateApiUrl / GetUsdtRate can consult admin-configured values.
|
||||||
config.SettingsGetString = func(key string) string {
|
config.SettingsGetString = func(key string) string {
|
||||||
|
|||||||
@@ -352,7 +352,13 @@ func GetRateForCoin(coin string, base string) float64 {
|
|||||||
if coin == "usdt" && base == "usd" {
|
if coin == "usdt" && base == "usd" {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
return getRateForCoinFromAPI(coin, base)
|
if rate := getRateForCoinFromAPI(coin, base); rate > 0 {
|
||||||
|
return rate
|
||||||
|
}
|
||||||
|
if coin == "ton" {
|
||||||
|
return getRateForCoinFromAPI("toncoin", base)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func getForcedRateForCoin(coin string, base string) float64 {
|
func getForcedRateForCoin(coin string, base string) float64 {
|
||||||
|
|||||||
@@ -329,6 +329,35 @@ func TestGetRateForCoinUsesAPIWhenForcedPairMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetRateForCoinUsesToncoinAPIFallbackForTon(t *testing.T) {
|
||||||
|
viper.Reset()
|
||||||
|
t.Cleanup(viper.Reset)
|
||||||
|
t.Setenv("API_RATE_URL", "")
|
||||||
|
|
||||||
|
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||||
|
if r.URL.Path != "/cny.json" {
|
||||||
|
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(`{"cny":{"toncoin":1.82}}`)),
|
||||||
|
Request: r,
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
installSettingsGetter(t, map[string]string{
|
||||||
|
"rate.forced_rate_list": `{"cny":{"ton":0}}`,
|
||||||
|
"rate.api_url": "https://rate.example.test",
|
||||||
|
})
|
||||||
|
|
||||||
|
rate := GetRateForCoin("ton", "cny")
|
||||||
|
if math.Abs(rate-1.82) > 1e-9 {
|
||||||
|
t.Fatalf("GetRateForCoin(ton, cny) = %v, want 1.82", rate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
|
func TestGetUsdtRateReturnsZeroWhenAPIUnavailableWithoutAdminOverride(t *testing.T) {
|
||||||
viper.Reset()
|
viper.Reset()
|
||||||
t.Cleanup(viper.Reset)
|
t.Cleanup(viper.Reset)
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ type UpdateChainRequest struct {
|
|||||||
DisplayName *string `json:"display_name" example:"Tron"`
|
DisplayName *string `json:"display_name" example:"Tron"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListChains returns the chains table (tron/ethereum/solana seeded; new
|
// ListChains returns the chains table for all seeded supported networks.
|
||||||
// networks like bsc/polygon inserted manually once supported).
|
|
||||||
// @Summary List chains
|
// @Summary List chains
|
||||||
// @Description Returns all supported blockchain networks
|
// @Description Returns all supported blockchain networks
|
||||||
// @Tags Admin Chains
|
// @Tags Admin Chains
|
||||||
@@ -41,7 +40,7 @@ func (c *BaseAdminController) ListChains(ctx echo.Context) error {
|
|||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param network path string true "Network name (e.g. tron, ethereum, solana)"
|
// @Param network path string true "Network name (e.g. ton, tron, ethereum, solana)"
|
||||||
// @Param request body admin.UpdateChainRequest true "Chain settings"
|
// @Param request body admin.UpdateChainRequest true "Chain settings"
|
||||||
// @Success 200 {object} response.ApiResponse
|
// @Success 200 {object} response.ApiResponse
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|||||||
if order.ID == 0 {
|
if order.ID == 0 {
|
||||||
return c.FailJson(ctx, constant.OrderNotExists)
|
return c.FailJson(ctx, constant.OrderNotExists)
|
||||||
}
|
}
|
||||||
if order.Status != mdb.StatusWaitPay {
|
if order.Status != mdb.StatusWaitPay && order.Status != mdb.StatusWaitSelect {
|
||||||
return c.FailJson(ctx, constant.OrderNotWaitPay)
|
return c.FailJson(ctx, constant.OrderNotWaitPay)
|
||||||
}
|
}
|
||||||
ok, err := data.CloseOrderManually(tradeID)
|
ok, err := data.CloseOrderManually(tradeID)
|
||||||
@@ -122,8 +122,16 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return c.FailJson(ctx, constant.OrderStatusConflict)
|
return c.FailJson(ctx, constant.OrderStatusConflict)
|
||||||
}
|
}
|
||||||
// Release the transaction lock so the amount slot becomes reusable.
|
// Release only concrete on-chain locks. Placeholder orders and provider-only
|
||||||
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
// orders do not reserve a local wallet amount.
|
||||||
|
if order.Status == mdb.StatusWaitPay &&
|
||||||
|
(strings.TrimSpace(order.PayProvider) == "" || order.PayProvider == mdb.PaymentProviderOnChain) &&
|
||||||
|
strings.TrimSpace(order.Network) != "" &&
|
||||||
|
strings.TrimSpace(order.ReceiveAddress) != "" &&
|
||||||
|
strings.TrimSpace(order.Token) != "" &&
|
||||||
|
order.ActualAmount > 0 {
|
||||||
|
_ = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount)
|
||||||
|
}
|
||||||
return c.SucJson(ctx, nil)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,6 +140,7 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|||||||
// callback + notification fire normally.
|
// callback + notification fire normally.
|
||||||
// @Summary Mark order paid
|
// @Summary Mark order paid
|
||||||
// @Description Manually mark a waiting order as paid (operator补单)
|
// @Description Manually mark a waiting order as paid (operator补单)
|
||||||
|
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
|
||||||
// @Tags Admin Orders
|
// @Tags Admin Orders
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import (
|
|||||||
type CreateRpcNodeRequest struct {
|
type CreateRpcNodeRequest struct {
|
||||||
Network string `json:"network" validate:"required" example:"tron"`
|
Network string `json:"network" validate:"required" example:"tron"`
|
||||||
Url string `json:"url" validate:"required" example:"https://api.trongrid.io"`
|
Url string `json:"url" validate:"required" example:"https://api.trongrid.io"`
|
||||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
// 连接类型 http=HTTP请求 ws=WebSocket长连接 lite=TON liteserver配置
|
||||||
Type string `json:"type" validate:"required|in:http,ws" enums:"http,ws" example:"http"`
|
Type string `json:"type" validate:"required|in:http,ws,lite" enums:"http,ws,lite" example:"http"`
|
||||||
Weight int `json:"weight" example:"1"`
|
Weight int `json:"weight" example:"1"`
|
||||||
ApiKey string `json:"api_key" example:""`
|
ApiKey string `json:"api_key" example:""`
|
||||||
Enabled *bool `json:"enabled" example:"true"`
|
Enabled *bool `json:"enabled" example:"true"`
|
||||||
@@ -187,9 +187,8 @@ func (c *BaseAdminController) DeleteRpcNode(ctx echo.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HealthCheckRpcNode performs an on-demand probe and writes the result.
|
// HealthCheckRpcNode performs an on-demand probe and writes the result.
|
||||||
// For HTTP endpoints this is a GET to the URL with a short timeout; for
|
// For HTTP/WS/lite endpoints this is a TCP-level check against the configured
|
||||||
// WS we just attempt a TCP-level check via the same HTTP client (the
|
// URL host. TON lite rows usually point to a global.config.json HTTPS URL.
|
||||||
// handshake URL resolves the host identically).
|
|
||||||
// @Summary Health check RPC node
|
// @Summary Health check RPC node
|
||||||
// @Description Perform an on-demand health probe on an RPC node
|
// @Description Perform an on-demand health probe on an RPC node
|
||||||
// @Tags Admin RPC Nodes
|
// @Tags Admin RPC Nodes
|
||||||
@@ -253,6 +252,11 @@ func validateRpcNodeURLForType(rawURL string, nodeType string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return constant.RpcNodeWebSocketURLErr
|
return constant.RpcNodeWebSocketURLErr
|
||||||
|
case mdb.RpcNodeTypeLite:
|
||||||
|
if scheme == "http" || scheme == "https" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return constant.RpcNodeHTTPURLErr
|
||||||
default:
|
default:
|
||||||
return constant.RpcNodeTypeErr
|
return constant.RpcNodeTypeErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/telegram"
|
"github.com/GMWalletApp/epusdt/telegram"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
appLog "github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/GMWalletApp/epusdt/util/security"
|
"github.com/GMWalletApp/epusdt/util/security"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
@@ -19,15 +20,15 @@ import (
|
|||||||
// Supported groups and keys:
|
// Supported groups and keys:
|
||||||
//
|
//
|
||||||
// - group=rate:
|
// - group=rate:
|
||||||
// rate.forced_rate_list (json) — override rate map, e.g. {"cny":{"usdt":0.14635}}; base/coin keys are normalized to lowercase
|
// rate.forced_rate_list (json) — override rate map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase
|
||||||
// rate.api_url (string) — external rate API URL used when no positive forced rate exists
|
// rate.api_url (string) — external rate API URL used when no positive forced rate exists
|
||||||
// rate.adjust_percent (float) — rate adjustment percentage
|
// rate.adjust_percent (float) — rate adjustment percentage
|
||||||
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
// rate.okx_c2c_enabled (bool) — use OKX C2C rate feed
|
||||||
//
|
//
|
||||||
// - group=epay:
|
// - group=epay:
|
||||||
// epay.default_token (string) — token for EPAY orders, e.g. "usdt" (default)
|
// epay.default_token (string) — default token for EPAY submit.php, e.g. "usdt"; empty allows status=4 placeholders when request token/network are also absent
|
||||||
// epay.default_currency (string) — fiat currency for EPAY orders, e.g. "cny" (default)
|
// epay.default_currency (string) — default fiat currency for EPAY submit.php, e.g. "cny"; empty falls back to cny
|
||||||
// epay.default_network (string) — blockchain network for EPAY orders, e.g. "tron" (default)
|
// epay.default_network (string) — default network for EPAY submit.php, e.g. "tron" or "ton"; empty allows status=4 placeholders when request token/network are also absent
|
||||||
//
|
//
|
||||||
// - group=okpay:
|
// - group=okpay:
|
||||||
// okpay.enabled (bool) — enable OkPay as a switch-network payment option
|
// okpay.enabled (bool) — enable OkPay as a switch-network payment option
|
||||||
@@ -54,6 +55,7 @@ import (
|
|||||||
// - group=system:
|
// - group=system:
|
||||||
// system.order_expiration_time (int) — order expiry in minutes
|
// system.order_expiration_time (int) — order expiry in minutes
|
||||||
// system.amount_precision (int) — payment amount precision, 2-6 decimals (default 2)
|
// system.amount_precision (int) — payment amount precision, 2-6 decimals (default 2)
|
||||||
|
// system.log_level (string) — runtime log level: debug, info, warn, error (default error)
|
||||||
type SettingUpsertItem struct {
|
type SettingUpsertItem struct {
|
||||||
Group string `json:"group" enums:"brand,rate,system,epay,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"`
|
||||||
@@ -93,11 +95,11 @@ func (c *BaseAdminController) ListSettings(ctx echo.Context) error {
|
|||||||
// @Summary Upsert settings
|
// @Summary Upsert settings
|
||||||
// @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
|
// @Description Batch insert/update settings. Returns per-key status; failed items include error_code for frontend i18n.
|
||||||
// @Description Supported groups: brand, rate, system, epay, okpay.
|
// @Description Supported groups: brand, rate, system, epay, okpay.
|
||||||
// @Description epay group keys: epay.default_token (e.g. "usdt"), epay.default_currency (e.g. "cny"), epay.default_network (e.g. "tron").
|
// @Description epay group keys: epay.default_token (e.g. "usdt" or "ton", empty allows status=4 placeholders), epay.default_currency (e.g. "cny", empty falls back to cny), epay.default_network (e.g. "tron" or "ton", empty allows status=4 placeholders).
|
||||||
// @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens.
|
// @Description okpay group keys: okpay.enabled, okpay.shop_id, okpay.shop_token, okpay.api_url, okpay.callback_url, okpay.return_url, okpay.timeout_seconds, okpay.allow_tokens.
|
||||||
// @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
// @Description rate group keys: rate.forced_rate_list (JSON map, e.g. {"cny":{"usdt":0.14635,"ton":0.5}}; base/coin keys are normalized to lowercase), rate.api_url, rate.adjust_percent, rate.okx_c2c_enabled.
|
||||||
// @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported.
|
// @Description brand group keys: brand.checkout_name, brand.logo_url, brand.site_title, brand.success_copy, brand.support_url, brand.background_color, brand.background_image_url. Legacy aliases brand.site_name, brand.page_title and brand.pay_success_text are also supported.
|
||||||
// @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), system.log_level (debug|info|warn|error, default error).
|
||||||
// @Tags Admin Settings
|
// @Tags Admin Settings
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Accept json
|
// @Accept json
|
||||||
@@ -149,10 +151,20 @@ func (c *BaseAdminController) UpsertSettings(ctx echo.Context) error {
|
|||||||
item.Group = mdb.SettingGroupRate
|
item.Group = mdb.SettingGroupRate
|
||||||
item.Type = mdb.SettingTypeJSON
|
item.Type = mdb.SettingTypeJSON
|
||||||
}
|
}
|
||||||
|
if key == mdb.SettingKeySystemLogLevel {
|
||||||
|
item.Group = mdb.SettingGroupSystem
|
||||||
|
item.Type = mdb.SettingTypeString
|
||||||
|
}
|
||||||
if err := data.SetSetting(item.Group, key, value, item.Type); err != nil {
|
if err := data.SetSetting(item.Group, key, value, item.Type); err != nil {
|
||||||
out = append(out, errorResult(key, err))
|
out = append(out, errorResult(key, err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if key == mdb.SettingKeySystemLogLevel {
|
||||||
|
if err := appLog.SetLevel(value); err != nil {
|
||||||
|
out = append(out, errorResult(key, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
out = append(out, result{Key: key, OK: true})
|
out = append(out, result{Key: key, OK: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +230,15 @@ func normalizeAndValidateSettingItem(group, key, value string) (string, error) {
|
|||||||
if err := security.ValidatePublicHTTPURL(value); err != nil {
|
if err := security.ValidatePublicHTTPURL(value); err != nil {
|
||||||
return value, fmt.Errorf("%s invalid: %w", key, err)
|
return value, fmt.Errorf("%s invalid: %w", key, err)
|
||||||
}
|
}
|
||||||
|
case mdb.SettingKeySystemLogLevel:
|
||||||
|
if strings.ToLower(strings.TrimSpace(group)) != mdb.SettingGroupSystem {
|
||||||
|
return value, fmt.Errorf("%s must use group %s", key, mdb.SettingGroupSystem)
|
||||||
|
}
|
||||||
|
normalized, err := appLog.NormalizeLevel(value)
|
||||||
|
if err != nil {
|
||||||
|
return value, err
|
||||||
|
}
|
||||||
|
return normalized, nil
|
||||||
}
|
}
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
@@ -274,7 +295,7 @@ func normalizeForcedRateListSetting(group, key, value string) (string, error) {
|
|||||||
// DeleteSetting removes one row. The next read of that key will fall
|
// DeleteSetting removes one row. The next read of that key will fall
|
||||||
// back to the hardcoded default (see settings_data.GetSetting*).
|
// back to the hardcoded default (see settings_data.GetSetting*).
|
||||||
// @Summary Delete setting
|
// @Summary Delete setting
|
||||||
// @Description Remove a setting by key (falls back to default)
|
// @Description Remove a setting by key (falls back to default). Deleting system.log_level immediately resets runtime logging to error.
|
||||||
// @Tags Admin Settings
|
// @Tags Admin Settings
|
||||||
// @Security AdminJWT
|
// @Security AdminJWT
|
||||||
// @Produce json
|
// @Produce json
|
||||||
@@ -290,5 +311,10 @@ func (c *BaseAdminController) DeleteSetting(ctx echo.Context) error {
|
|||||||
if err := data.DeleteSetting(key); err != nil {
|
if err := data.DeleteSetting(key); err != nil {
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
if key == mdb.SettingKeySystemLogLevel {
|
||||||
|
if err := appLog.SetLevel(mdb.SettingDefaultSystemLogLevel); err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return c.SucJson(ctx, nil)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
|
|||||||
// CreateTransaction 创建交易
|
// CreateTransaction 创建交易
|
||||||
// @Summary Create transaction
|
// @Summary Create transaction
|
||||||
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
|
// @Description Create a payment transaction order. Accepts JSON body (application/json) or form-encoded body (application/x-www-form-urlencoded).
|
||||||
|
// @Description GMPay may omit both token and network to create a status=4 placeholder order; EPay submit.php can also create one when neither request parameters nor database defaults provide token/network. Supplying only one of token/network is invalid.
|
||||||
|
// @Description payment_type is optional for GMPay. If it is sent, it is a normal signed parameter and must be included when calculating signature.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Accept x-www-form-urlencoded
|
// @Accept x-www-form-urlencoded
|
||||||
@@ -32,16 +34,16 @@ func apiKeyFromContext(ctx echo.Context) *mdb.ApiKey {
|
|||||||
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
// @Param request body request.CreateTransactionRequest false "Transaction payload (JSON)"
|
||||||
// @Param order_id formData string false "Merchant order ID"
|
// @Param order_id formData string false "Merchant order ID"
|
||||||
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
// @Param currency formData string false "Fiat currency (e.g. cny)"
|
||||||
// @Param token formData string false "Crypto token (e.g. usdt)"
|
// @Param token formData string false "Crypto token (e.g. TON, USDT); omit together with network to create a placeholder where supported"
|
||||||
// @Param network formData string false "Network (e.g. tron)"
|
// @Param network formData string false "Network (e.g. ton, tron); omit together with token to create a placeholder where supported"
|
||||||
// @Param amount formData number false "Amount"
|
// @Param amount formData number false "Amount"
|
||||||
// @Param notify_url formData string false "Callback URL"
|
// @Param notify_url formData string false "Callback URL"
|
||||||
// @Param signature formData string false "MD5 signature"
|
// @Param signature formData string false "MD5 signature"
|
||||||
// @Param redirect_url formData string false "Redirect URL"
|
// @Param redirect_url formData string false "Redirect URL"
|
||||||
// @Param name formData string false "Order name"
|
// @Param name formData string false "Order name"
|
||||||
// @Param payment_type formData string false "Payment type"
|
// @Param payment_type formData string false "Optional GMPay compatibility flag; include in signature when sent"
|
||||||
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
|
// @Success 200 {object} response.ApiResponse{data=response.CreateTransactionResponse}
|
||||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10003 no wallet, 10005 no amount channel"
|
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel"
|
||||||
// @Router /payments/gmpay/v1/order/create-transaction [post]
|
// @Router /payments/gmpay/v1/order/create-transaction [post]
|
||||||
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
||||||
req := new(request.CreateTransactionRequest)
|
req := new(request.CreateTransactionRequest)
|
||||||
@@ -58,17 +60,17 @@ func (c *BaseCommController) CreateTransaction(ctx echo.Context) (err error) {
|
|||||||
return c.SucJson(ctx, resp)
|
return c.SucJson(ctx, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchNetwork 切换支付网络,创建或返回子订单
|
// SwitchNetwork 切换支付网络,补全占位父单或创建/返回子订单
|
||||||
// @Summary Switch payment network
|
// @Summary Switch payment network
|
||||||
// @Description Switch to a different payment target, creating or returning a sub-order.
|
// @Description Switch to a different payment target. A status=4 placeholder is completed in place and returns the same parent trade_id with is_selected=false; an already concrete status=1 parent creates or returns the only sub-order when switching to a different target.
|
||||||
// @Description Normal values such as tron/solana/ethereum create on-chain child orders.
|
// @Description Normal values such as ton/tron/solana/ethereum select on-chain payment; the special value okpay selects OkPay hosted payment.
|
||||||
// @Description The special value okpay creates or reuses an OkPay-hosted child order and returns its payment_url.
|
// @Description For status=4 placeholders from GMPay or EPay submit.php, both on-chain targets and okpay complete the parent in place without creating a child order. Sub-orders cannot be switched again.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
|
// @Param request body request.SwitchNetworkRequest true "Switch network payload"
|
||||||
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
||||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10012 cannot switch sub-order, 10013 not waiting payment, 10017/10018/10019 provider errors, 10042 provider order creation failed"
|
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10008 order not found, 10011 sub-order limit, 10012 cannot switch sub-order, 10013 not waiting payment, 10016 unsupported asset, 10017/10018/10019 provider errors, 10042 provider order creation failed"
|
||||||
// @Router /pay/switch-network [post]
|
// @Router /pay/switch-network [post]
|
||||||
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
||||||
req := new(request.SwitchNetworkRequest)
|
req := new(request.SwitchNetworkRequest)
|
||||||
@@ -98,7 +100,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
|||||||
// POST (form) per the legacy EPAY protocol; swagger documents POST as
|
// POST (form) per the legacy EPAY protocol; swagger documents POST as
|
||||||
// the canonical form — the GET variant is identical save the transport.
|
// the canonical form — the GET variant is identical save the transport.
|
||||||
// @Summary Create transaction and redirect (EPAY compat)
|
// @Summary Create transaction and redirect (EPAY compat)
|
||||||
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid.
|
// @Description Legacy EPAY-style endpoint. Accepts GET (querystring) and POST (form). On success, 302 redirects to /pay/checkout-counter/{trade_id}. Signature uses MD5 of sorted params + secret_key of the api_keys row matching the submitted pid. Optional request token/network/currency override database defaults and must be included in the EPay signature when sent. The server injects internal payment_type=Epay after EPay signature verification; merchants do not send GMPay payment_type to this endpoint.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Accept x-www-form-urlencoded
|
// @Accept x-www-form-urlencoded
|
||||||
// @Produce html
|
// @Produce html
|
||||||
@@ -121,7 +123,7 @@ func (c *BaseCommController) SwitchNetwork(ctx echo.Context) (err error) {
|
|||||||
// @Param sign formData string true "MD5 signature"
|
// @Param sign formData string true "MD5 signature"
|
||||||
// @Param sign_type formData string false "Signature type (MD5)"
|
// @Param sign_type formData string false "Signature type (MD5)"
|
||||||
// @Success 302 "Redirect to checkout counter"
|
// @Success 302 "Redirect to checkout counter"
|
||||||
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10003 no wallet, 10005 no amount channel"
|
// @Failure 400 {object} response.ApiResponse "Stable errno in status_code: 10009 invalid params, 10041 invalid notify_url, 10004 invalid amount, 10014 chain disabled, 10016 unsupported asset, 10003 no wallet, 10005 no amount channel"
|
||||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
|
// @Router /payments/epay/v1/order/create-transaction/submit.php [post]
|
||||||
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
|
// @Router /payments/epay/v1/order/create-transaction/submit.php [get]
|
||||||
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CreateTransactionAndRedirect(ctx echo.Context) (err error) {
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ import (
|
|||||||
|
|
||||||
// CheckoutCounter 收银台
|
// CheckoutCounter 收银台
|
||||||
// @Summary Checkout counter page
|
// @Summary Checkout counter page
|
||||||
// @Description Return checkout initialization data when the order exists. This endpoint only confirms order existence and returns base order data; call /pay/check-status/{trade_id} for the current order status (1=waiting payment, 2=paid, 3=expired).
|
// @Description Return checkout initialization data when the order exists. This endpoint only confirms order existence and returns base order data; call /pay/check-status/{trade_id} for the current order status (1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection).
|
||||||
|
// @Description When status=4, actual_amount is 0 and token/network/receive_address are empty; this state is produced by GMPay placeholders or EPay submit.php when no token/network request values or database defaults exist. The cashier should guide the payer to choose an on-chain token/network or OkPay and then call /pay/switch-network.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param trade_id path string true "Trade ID"
|
// @Param trade_id path string true "Trade ID"
|
||||||
@@ -31,7 +32,7 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
|||||||
|
|
||||||
// CheckStatus 支付状态检测
|
// CheckStatus 支付状态检测
|
||||||
// @Summary Check payment status
|
// @Summary Check payment status
|
||||||
// @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired.
|
// @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired, 4=waiting token/network selection.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param trade_id path string true "Trade ID"
|
// @Param trade_id path string true "Trade ID"
|
||||||
@@ -54,6 +55,7 @@ func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
|||||||
// SubmitTxHash 手动提交交易 hash 补单
|
// SubmitTxHash 手动提交交易 hash 补单
|
||||||
// @Summary Submit transaction hash
|
// @Summary Submit transaction hash
|
||||||
// @Description Submit an on-chain transaction hash from the cashier. The hash is verified against the order and, when valid, the same manual payment processing path used by admin mark-paid is executed. OkPay/provider orders are not supported.
|
// @Description Submit an on-chain transaction hash from the cashier. The hash is verified against the order and, when valid, the same manual payment processing path used by admin mark-paid is executed. OkPay/provider orders are not supported.
|
||||||
|
// @Description TON accepts canonical ton:<receive_raw>:<lt>:<hash>, lt:hash, or a unique recent hash-only reference for the order receive address.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
|||||||
@@ -109,9 +109,9 @@ func (c *BaseCommController) GetPublicConfig(ctx echo.Context) error {
|
|||||||
BackgroundImageURL: data.GetBrandBackgroundImageURL(),
|
BackgroundImageURL: data.GetBrandBackgroundImageURL(),
|
||||||
},
|
},
|
||||||
Epay: response.EpayPublicConfig{
|
Epay: response.EpayPublicConfig{
|
||||||
DefaultToken: data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
|
DefaultToken: data.GetSettingString(mdb.SettingKeyEpayDefaultToken, ""),
|
||||||
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
DefaultCurrency: data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
||||||
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
|
DefaultNetwork: data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, ""),
|
||||||
},
|
},
|
||||||
OkPay: okpay,
|
OkPay: okpay,
|
||||||
Version: config.GetAppVersion(),
|
Version: config.GetAppVersion(),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ require (
|
|||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/spf13/viper v1.20.1
|
github.com/spf13/viper v1.20.1
|
||||||
github.com/tidwall/gjson v1.18.0
|
github.com/tidwall/gjson v1.18.0
|
||||||
|
github.com/xssnick/tonutils-go v1.16.0
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
golang.org/x/crypto v0.48.0
|
golang.org/x/crypto v0.48.0
|
||||||
gopkg.in/telebot.v3 v3.0.0
|
gopkg.in/telebot.v3 v3.0.0
|
||||||
|
|||||||
@@ -348,6 +348,8 @@ github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 h1:QldyIu/L63oPpyvQmHg
|
|||||||
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
|
||||||
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
|
||||||
|
github.com/xssnick/tonutils-go v1.16.0 h1:MLYVZiiMB0Q+XudLo7WyjOli4gMuenNmJpBqETqa5DE=
|
||||||
|
github.com/xssnick/tonutils-go v1.16.0/go.mod h1:CNSM+L0FYufOFhprg8otc9Y/ecFfLrWqFMq00Gh9yk4=
|
||||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
"gorm.io/gorm/schema"
|
"gorm.io/gorm/schema"
|
||||||
)
|
)
|
||||||
@@ -65,11 +66,19 @@ func SetupTestDatabases(t testing.TB) func() {
|
|||||||
// Seed all standard chains as enabled so IsChainEnabled checks pass.
|
// Seed all standard chains as enabled so IsChainEnabled checks pass.
|
||||||
for _, network := range []string{
|
for _, network := range []string{
|
||||||
mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum,
|
mdb.NetworkTron, mdb.NetworkSolana, mdb.NetworkEthereum,
|
||||||
mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma,
|
mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkTon,
|
||||||
} {
|
} {
|
||||||
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
|
mainDB.Create(&mdb.Chain{Network: network, Enabled: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mainDB.Clauses(clause.OnConflict{DoNothing: true}).Create(&[]mdb.ChainToken{
|
||||||
|
{Network: mdb.NetworkTron, Symbol: "USDT", ContractAddress: "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", Decimals: 6, Enabled: true},
|
||||||
|
{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7", Decimals: 6, Enabled: true},
|
||||||
|
{Network: mdb.NetworkBsc, Symbol: "USDT", ContractAddress: "0x55d398326f99059fF775485246999027B3197955", Decimals: 18, Enabled: true},
|
||||||
|
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||||
|
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true},
|
||||||
|
})
|
||||||
|
|
||||||
// Seed two universal api_keys rows. Both usable for EPAY/GMPAY
|
// Seed two universal api_keys rows. Both usable for EPAY/GMPAY
|
||||||
// flows; the numeric PID 1001 row lets legacy tests that submit
|
// flows; the numeric PID 1001 row lets legacy tests that submit
|
||||||
// `pid=1001` still match.
|
// `pid=1001` still match.
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func seedChains() {
|
|||||||
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
{Network: mdb.NetworkBsc, DisplayName: "BSC", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||||
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
{Network: mdb.NetworkPolygon, DisplayName: "Polygon", Enabled: true, MinConfirmations: 3, ScanIntervalSec: 5},
|
||||||
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
{Network: mdb.NetworkPlasma, DisplayName: "Plasma", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||||
|
{Network: mdb.NetworkTon, DisplayName: "TON", Enabled: true, MinConfirmations: 1, ScanIntervalSec: 5},
|
||||||
}
|
}
|
||||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||||
color.Red.Printf("[store_db] seed chains err=%s\n", err)
|
color.Red.Printf("[store_db] seed chains err=%s\n", err)
|
||||||
@@ -98,6 +99,9 @@ func seedChainTokens() {
|
|||||||
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
|
{Network: mdb.NetworkPolygon, Symbol: "USDC.e", ContractAddress: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174", Decimals: 6, Enabled: true},
|
||||||
// Plasma
|
// Plasma
|
||||||
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
|
{Network: mdb.NetworkPlasma, Symbol: "USDT", ContractAddress: "0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb", Decimals: 6, Enabled: true},
|
||||||
|
// TON
|
||||||
|
{Network: mdb.NetworkTon, Symbol: "TON", ContractAddress: "", Decimals: 9, Enabled: true},
|
||||||
|
{Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe", Decimals: 6, Enabled: true},
|
||||||
}
|
}
|
||||||
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
if err := Mdb.Clauses(clause.OnConflict{DoNothing: true}).Create(&defaults).Error; err != nil {
|
||||||
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
|
color.Red.Printf("[store_db] seed chain_tokens err=%s\n", err)
|
||||||
@@ -133,6 +137,7 @@ func defaultRpcNodes() []mdb.RpcNode {
|
|||||||
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
{Network: mdb.NetworkBsc, Url: "wss://bsc.drpc.org", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
{Network: mdb.NetworkPolygon, Url: "wss://polygon-bor-rpc.publicnode.com", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
{Network: mdb.NetworkPlasma, Url: "wss://rpc.plasma.to", Type: mdb.RpcNodeTypeWs, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
|
{Network: mdb.NetworkTon, Url: "https://ton-blockchain.github.io/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusUnknown},
|
||||||
{Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
{Network: mdb.NetworkEthereum, Url: "https://rpc.epusdt.com/ethereum", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||||
{Network: mdb.NetworkBsc, Url: "https://rpc.epusdt.com/binance", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
{Network: mdb.NetworkBsc, Url: "https://rpc.epusdt.com/binance", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||||
{Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
{Network: mdb.NetworkPolygon, Url: "https://rpc.epusdt.com/polygon", Type: mdb.RpcNodeTypeHttp, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusUnknown},
|
||||||
@@ -157,9 +162,10 @@ func seedDefaultSettings() {
|
|||||||
}
|
}
|
||||||
defaults := []mdb.Setting{
|
defaults := []mdb.Setting{
|
||||||
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
|
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeyAmountPrecision, Value: "2", Type: mdb.SettingTypeInt},
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupSystem, Key: mdb.SettingKeySystemLogLevel, Value: mdb.SettingDefaultSystemLogLevel, Type: mdb.SettingTypeString},
|
||||||
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultCurrency, Value: "cny", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayEnabled, Value: "false", Type: mdb.SettingTypeBool},
|
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayEnabled, Value: "false", Type: mdb.SettingTypeBool},
|
||||||
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAPIURL, Value: "https://api.okaypay.me/shop/", Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayAPIURL, Value: "https://api.okaypay.me/shop/", Type: mdb.SettingTypeString},
|
||||||
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayCallbackURL, Value: okPayCallbackURL, Type: mdb.SettingTypeString},
|
{Group: mdb.SettingGroupOkPay, Key: mdb.SettingKeyOkPayCallbackURL, Value: okPayCallbackURL, Type: mdb.SettingTypeString},
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package dao
|
package dao
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/libtnb/sqlite"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultRpcNodesIncludesManualVerifyEpusdtEvmNodes(t *testing.T) {
|
func TestDefaultRpcNodesIncludesManualVerifyEpusdtEvmNodes(t *testing.T) {
|
||||||
@@ -41,3 +45,152 @@ func TestDefaultRpcNodesIncludesManualVerifyEpusdtEvmNodes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultRpcNodesIncludesTonLiteGeneralNode(t *testing.T) {
|
||||||
|
var got *mdb.RpcNode
|
||||||
|
nodes := defaultRpcNodes()
|
||||||
|
for i := range nodes {
|
||||||
|
node := nodes[i]
|
||||||
|
if node.Network == mdb.NetworkTon && node.Type == mdb.RpcNodeTypeLite {
|
||||||
|
got = &node
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("missing TON lite seed rpc node")
|
||||||
|
}
|
||||||
|
if got.Url != "https://ton-blockchain.github.io/global.config.json" {
|
||||||
|
t.Fatalf("TON lite seed url = %q", got.Url)
|
||||||
|
}
|
||||||
|
if got.Purpose != mdb.RpcNodePurposeGeneral {
|
||||||
|
t.Fatalf("TON lite seed purpose = %q, want %q", got.Purpose, mdb.RpcNodePurposeGeneral)
|
||||||
|
}
|
||||||
|
if !got.Enabled {
|
||||||
|
t.Fatal("TON lite seed enabled = false, want true")
|
||||||
|
}
|
||||||
|
if got.Status != mdb.RpcNodeStatusUnknown {
|
||||||
|
t.Fatalf("TON lite seed status = %q, want %q", got.Status, mdb.RpcNodeStatusUnknown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedDefaultSettingsIncludesSystemLogLevel(t *testing.T) {
|
||||||
|
db := setupSeedSettingsTestDB(t)
|
||||||
|
Mdb = db
|
||||||
|
|
||||||
|
seedDefaultSettings()
|
||||||
|
|
||||||
|
var row mdb.Setting
|
||||||
|
if err := Mdb.Where("`key` = ?", mdb.SettingKeySystemLogLevel).Take(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load system.log_level seed: %v", err)
|
||||||
|
}
|
||||||
|
if row.Group != mdb.SettingGroupSystem {
|
||||||
|
t.Fatalf("system.log_level group = %q, want %q", row.Group, mdb.SettingGroupSystem)
|
||||||
|
}
|
||||||
|
if row.Value != mdb.SettingDefaultSystemLogLevel {
|
||||||
|
t.Fatalf("system.log_level value = %q, want %q", row.Value, mdb.SettingDefaultSystemLogLevel)
|
||||||
|
}
|
||||||
|
if row.Type != mdb.SettingTypeString {
|
||||||
|
t.Fatalf("system.log_level type = %q, want %q", row.Type, mdb.SettingTypeString)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedDefaultSettingsUsesEmptyEpayTokenAndNetwork(t *testing.T) {
|
||||||
|
db := setupSeedSettingsTestDB(t)
|
||||||
|
Mdb = db
|
||||||
|
|
||||||
|
seedDefaultSettings()
|
||||||
|
|
||||||
|
rows := make(map[string]mdb.Setting)
|
||||||
|
for _, key := range []string{
|
||||||
|
mdb.SettingKeyEpayDefaultToken,
|
||||||
|
mdb.SettingKeyEpayDefaultCurrency,
|
||||||
|
mdb.SettingKeyEpayDefaultNetwork,
|
||||||
|
} {
|
||||||
|
var row mdb.Setting
|
||||||
|
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load %s seed: %v", key, err)
|
||||||
|
}
|
||||||
|
rows[key] = row
|
||||||
|
}
|
||||||
|
if rows[mdb.SettingKeyEpayDefaultToken].Value != "" {
|
||||||
|
t.Fatalf("epay.default_token seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultToken].Value)
|
||||||
|
}
|
||||||
|
if rows[mdb.SettingKeyEpayDefaultNetwork].Value != "" {
|
||||||
|
t.Fatalf("epay.default_network seed = %q, want empty", rows[mdb.SettingKeyEpayDefaultNetwork].Value)
|
||||||
|
}
|
||||||
|
if rows[mdb.SettingKeyEpayDefaultCurrency].Value != "cny" {
|
||||||
|
t.Fatalf("epay.default_currency seed = %q, want cny", rows[mdb.SettingKeyEpayDefaultCurrency].Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedDefaultSettingsDoesNotOverwriteExistingEpayDefaults(t *testing.T) {
|
||||||
|
db := setupSeedSettingsTestDB(t)
|
||||||
|
Mdb = db
|
||||||
|
for _, row := range []mdb.Setting{
|
||||||
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultToken, Value: "usdt", Type: mdb.SettingTypeString},
|
||||||
|
{Group: mdb.SettingGroupEpay, Key: mdb.SettingKeyEpayDefaultNetwork, Value: "tron", Type: mdb.SettingTypeString},
|
||||||
|
} {
|
||||||
|
if err := Mdb.Create(&row).Error; err != nil {
|
||||||
|
t.Fatalf("precreate %s: %v", row.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedDefaultSettings()
|
||||||
|
|
||||||
|
for key, want := range map[string]string{
|
||||||
|
mdb.SettingKeyEpayDefaultToken: "usdt",
|
||||||
|
mdb.SettingKeyEpayDefaultNetwork: "tron",
|
||||||
|
} {
|
||||||
|
var row mdb.Setting
|
||||||
|
if err := Mdb.Where("`key` = ?", key).Take(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load %s seed: %v", key, err)
|
||||||
|
}
|
||||||
|
if row.Value != want {
|
||||||
|
t.Fatalf("%s value = %q, want existing %q", key, row.Value, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSeedDefaultSettingsDoesNotOverwriteSystemLogLevel(t *testing.T) {
|
||||||
|
db := setupSeedSettingsTestDB(t)
|
||||||
|
Mdb = db
|
||||||
|
if err := Mdb.Create(&mdb.Setting{
|
||||||
|
Group: mdb.SettingGroupSystem,
|
||||||
|
Key: mdb.SettingKeySystemLogLevel,
|
||||||
|
Value: "debug",
|
||||||
|
Type: mdb.SettingTypeString,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("precreate system.log_level: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seedDefaultSettings()
|
||||||
|
|
||||||
|
var row mdb.Setting
|
||||||
|
if err := Mdb.Where("`key` = ?", mdb.SettingKeySystemLogLevel).Take(&row).Error; err != nil {
|
||||||
|
t.Fatalf("load system.log_level seed: %v", err)
|
||||||
|
}
|
||||||
|
if row.Value != "debug" {
|
||||||
|
t.Fatalf("system.log_level value = %q, want existing debug", row.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupSeedSettingsTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
oldDB := Mdb
|
||||||
|
viper.Reset()
|
||||||
|
viper.Set("app_uri", "https://example.com")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
Mdb = oldDB
|
||||||
|
viper.Reset()
|
||||||
|
})
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "seed-settings.db")), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open sqlite: %v", err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&mdb.Setting{}); err != nil {
|
||||||
|
t.Fatalf("migrate settings: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,12 +105,12 @@ func CountOrdersByStatus() (map[int]int64, error) {
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CloseOrderManually transitions a pending order to expired. Only
|
// CloseOrderManually transitions a pending order to expired. It covers both
|
||||||
// touches rows currently in StatusWaitPay so idempotent / safe.
|
// payable orders and placeholder orders waiting for token/network selection.
|
||||||
func CloseOrderManually(tradeID string) (bool, error) {
|
func CloseOrderManually(tradeID string) (bool, error) {
|
||||||
result := dao.Mdb.Model(&mdb.Orders{}).
|
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
Where("trade_id = ?", tradeID).
|
Where("trade_id = ?", tradeID).
|
||||||
Where("status = ?", mdb.StatusWaitPay).
|
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
||||||
Update("status", mdb.StatusExpired)
|
Update("status", mdb.StatusExpired)
|
||||||
return result.RowsAffected > 0, result.Error
|
return result.RowsAffected > 0, result.Error
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -39,9 +40,15 @@ func normalizeLockNetwork(network string) string {
|
|||||||
|
|
||||||
func normalizeLockAddress(network, address string) string {
|
func normalizeLockAddress(network, address string) string {
|
||||||
address = strings.TrimSpace(address)
|
address = strings.TrimSpace(address)
|
||||||
if isEVMNetwork(normalizeLockNetwork(network)) {
|
network = normalizeLockNetwork(network)
|
||||||
|
if isEVMNetwork(network) {
|
||||||
return strings.ToLower(address)
|
return strings.ToLower(address)
|
||||||
}
|
}
|
||||||
|
if network == mdb.NetworkTon {
|
||||||
|
if raw, err := addressutil.TonRawAddressKey(address); err == nil {
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
}
|
||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,7 +209,7 @@ func SaveCallBackOrdersResp(order *mdb.Orders) error {
|
|||||||
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
|
func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, error) {
|
||||||
result := dao.Mdb.Model(mdb.Orders{}).
|
result := dao.Mdb.Model(mdb.Orders{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Where("status = ?", mdb.StatusWaitPay).
|
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
||||||
Where("created_at <= ?", expirationCutoff).
|
Where("created_at <= ?", expirationCutoff).
|
||||||
Update("status", mdb.StatusExpired)
|
Update("status", mdb.StatusExpired)
|
||||||
return result.RowsAffected > 0, result.Error
|
return result.RowsAffected > 0, result.Error
|
||||||
@@ -218,6 +225,15 @@ func CountActiveSubOrders(parentTradeId string) (int64, error) {
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountSubOrders counts all sub-orders ever created under a parent.
|
||||||
|
func CountSubOrders(parentTradeId string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Where("parent_trade_id = ?", parentTradeId).
|
||||||
|
Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent.
|
// GetSubOrderByTokenNetwork finds an existing active sub-order matching token+network under a parent.
|
||||||
func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) {
|
func GetSubOrderByTokenNetwork(parentTradeId string, token string, network string) (*mdb.Orders, error) {
|
||||||
order := new(mdb.Orders)
|
order := new(mdb.Orders)
|
||||||
@@ -281,6 +297,61 @@ func MarkOrderSelected(tradeId string) error {
|
|||||||
Update("is_selected", true).Error
|
Update("is_selected", true).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CompleteWaitSelectOrder fills a placeholder order with concrete chain fields
|
||||||
|
// and makes it payable. It only updates status=WaitSelect rows so concurrent
|
||||||
|
// switch-network attempts cannot overwrite a payable order.
|
||||||
|
func CompleteWaitSelectOrder(tradeID string, network string, token string, receiveAddress string, actualAmount float64) (bool, error) {
|
||||||
|
result := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("status = ?", mdb.StatusWaitSelect).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": mdb.StatusWaitPay,
|
||||||
|
"network": strings.ToLower(strings.TrimSpace(network)),
|
||||||
|
"token": strings.ToUpper(strings.TrimSpace(token)),
|
||||||
|
"receive_address": receiveAddress,
|
||||||
|
"actual_amount": actualAmount,
|
||||||
|
"is_selected": false,
|
||||||
|
"created_at": time.Now(),
|
||||||
|
})
|
||||||
|
return result.RowsAffected > 0, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteWaitSelectOkPayOrderWithTransaction converts a placeholder parent
|
||||||
|
// directly into an OkPay order. It intentionally does not create a local chain
|
||||||
|
// lock because OkPay owns the hosted payment target.
|
||||||
|
func CompleteWaitSelectOkPayOrderWithTransaction(tx *gorm.DB, tradeID string, token string, actualAmount float64) (bool, error) {
|
||||||
|
result := tx.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("status = ?", mdb.StatusWaitSelect).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": mdb.StatusWaitPay,
|
||||||
|
"network": mdb.PaymentProviderOkPay,
|
||||||
|
"token": strings.ToUpper(strings.TrimSpace(token)),
|
||||||
|
"receive_address": "OKPAY",
|
||||||
|
"actual_amount": actualAmount,
|
||||||
|
"is_selected": false,
|
||||||
|
"pay_provider": mdb.PaymentProviderOkPay,
|
||||||
|
"created_at": time.Now(),
|
||||||
|
})
|
||||||
|
return result.RowsAffected > 0, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkProviderSwitchParentSelectedWithTransaction marks a switch-network parent
|
||||||
|
// as selected for a hosted payment provider. It intentionally does not fill
|
||||||
|
// chain fields or create a transaction lock; the provider child order carries
|
||||||
|
// the concrete payment target.
|
||||||
|
func MarkProviderSwitchParentSelectedWithTransaction(tx *gorm.DB, tradeID string) (bool, error) {
|
||||||
|
result := tx.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": mdb.StatusWaitPay,
|
||||||
|
"is_selected": true,
|
||||||
|
"created_at": time.Now(),
|
||||||
|
})
|
||||||
|
return result.RowsAffected > 0, result.Error
|
||||||
|
}
|
||||||
|
|
||||||
// ExpireOrderByTradeID marks a waiting order as expired. Used to retire failed
|
// ExpireOrderByTradeID marks a waiting order as expired. Used to retire failed
|
||||||
// child-order attempts that should not remain selectable/reusable.
|
// child-order attempts that should not remain selectable/reusable.
|
||||||
func ExpireOrderByTradeID(tradeId string) error {
|
func ExpireOrderByTradeID(tradeId string) error {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
func TestEvmTransactionLockAddressIsCaseInsensitive(t *testing.T) {
|
||||||
@@ -156,3 +157,30 @@ func TestNonEvmTransactionLockAddressRemainsCaseSensitive(t *testing.T) {
|
|||||||
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
|
t.Fatalf("tron address lookup should remain case-sensitive, got trade id %q", gotTradeID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTonTransactionLockAddressUsesRawKey(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
bounceable := "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"
|
||||||
|
addr := address.MustParseAddr(bounceable)
|
||||||
|
nonBounce := addr.Bounce(false).String()
|
||||||
|
|
||||||
|
if err := LockTransaction(mdb.NetworkTon, bounceable, "TON", "trade-ton", 1.23, time.Hour); err != nil {
|
||||||
|
t.Fatalf("lock ton transaction: %v", err)
|
||||||
|
}
|
||||||
|
gotTradeID, err := GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, nonBounce, "TON", 1.23)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup ton lock: %v", err)
|
||||||
|
}
|
||||||
|
if gotTradeID != "trade-ton" {
|
||||||
|
t.Fatalf("ton lock lookup = %q, want trade-ton", gotTradeID)
|
||||||
|
}
|
||||||
|
gotTradeID, err = GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", 1.23)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup ton raw lock: %v", err)
|
||||||
|
}
|
||||||
|
if gotTradeID != "trade-ton" {
|
||||||
|
t.Fatalf("ton raw lock lookup = %q, want trade-ton", gotTradeID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GetProviderOrderByTradeIDAndProvider(tradeID string, provider string) (*mdb.ProviderOrder, error) {
|
func GetProviderOrderByTradeIDAndProvider(tradeID string, provider string) (*mdb.ProviderOrder, error) {
|
||||||
@@ -20,8 +21,27 @@ func CreateProviderOrderWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) err
|
|||||||
return tx.Model(row).Create(row).Error
|
return tx.Model(row).Create(row).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func UpsertProviderOrderCreatingWithTransaction(tx *gorm.DB, row *mdb.ProviderOrder) error {
|
||||||
|
return tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "trade_id"}, {Name: "provider"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"provider_order_id",
|
||||||
|
"pay_url",
|
||||||
|
"amount",
|
||||||
|
"coin",
|
||||||
|
"status",
|
||||||
|
"notify_raw",
|
||||||
|
"updated_at",
|
||||||
|
}),
|
||||||
|
}).Create(row).Error
|
||||||
|
}
|
||||||
|
|
||||||
func UpdateProviderOrderCreated(tradeID string, provider string, providerOrderID string, payURL string) error {
|
func UpdateProviderOrderCreated(tradeID string, provider string, providerOrderID string, payURL string) error {
|
||||||
return dao.Mdb.Model(&mdb.ProviderOrder{}).
|
return UpdateProviderOrderCreatedWithTransaction(dao.Mdb, tradeID, provider, providerOrderID, payURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UpdateProviderOrderCreatedWithTransaction(tx *gorm.DB, tradeID string, provider string, providerOrderID string, payURL string) error {
|
||||||
|
return tx.Model(&mdb.ProviderOrder{}).
|
||||||
Where("trade_id = ?", tradeID).
|
Where("trade_id = ?", tradeID).
|
||||||
Where("provider = ?", provider).
|
Where("provider = ?", provider).
|
||||||
Updates(map[string]interface{}{
|
Updates(map[string]interface{}{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,10 +35,23 @@ func normalizeWalletAddressByNetwork(network, address string) string {
|
|||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeWalletAddressByNetworkE(network, address string) (string, error) {
|
||||||
|
network = normalizeWalletNetwork(network)
|
||||||
|
address = strings.TrimSpace(address)
|
||||||
|
if network == mdb.NetworkTon {
|
||||||
|
return addressutil.NormalizeTonAddress(address)
|
||||||
|
}
|
||||||
|
return normalizeWalletAddressByNetwork(network, address), nil
|
||||||
|
}
|
||||||
|
|
||||||
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
// AddWalletAddressWithNetwork 创建指定网络的钱包地址
|
||||||
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
|
||||||
network = normalizeWalletNetwork(network)
|
network = normalizeWalletNetwork(network)
|
||||||
address = normalizeWalletAddressByNetwork(network, address)
|
var err error
|
||||||
|
address, err = normalizeWalletAddressByNetworkE(network, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
exist, err := GetWalletAddressByNetworkAndAddress(network, address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -75,9 +89,13 @@ func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, e
|
|||||||
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
|
// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
|
||||||
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
|
func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
|
||||||
network = normalizeWalletNetwork(network)
|
network = normalizeWalletNetwork(network)
|
||||||
address = normalizeWalletAddressByNetwork(network, address)
|
var err error
|
||||||
|
address, err = normalizeWalletAddressByNetworkE(network, address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
walletAddress := new(mdb.WalletAddress)
|
walletAddress := new(mdb.WalletAddress)
|
||||||
err := dao.Mdb.Model(walletAddress).
|
err = dao.Mdb.Model(walletAddress).
|
||||||
Where("network = ?", network).
|
Where("network = ?", network).
|
||||||
Where("address = ?", address).
|
Where("address = ?", address).
|
||||||
Limit(1).Find(walletAddress).Error
|
Limit(1).Find(walletAddress).Error
|
||||||
@@ -126,6 +144,12 @@ func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, er
|
|||||||
for i := range list {
|
for i := range list {
|
||||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||||
}
|
}
|
||||||
|
} else if network == mdb.NetworkTon {
|
||||||
|
for i := range list {
|
||||||
|
if normalized, err := addressutil.NormalizeTonAddress(list[i].Address); err == nil {
|
||||||
|
list[i].Address = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
@@ -149,6 +173,12 @@ func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
|
|||||||
for i := range list {
|
for i := range list {
|
||||||
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
list[i].Address = strings.ToLower(strings.TrimSpace(list[i].Address))
|
||||||
}
|
}
|
||||||
|
} else if network == mdb.NetworkTon {
|
||||||
|
for i := range list {
|
||||||
|
if normalized, err := addressutil.NormalizeTonAddress(list[i].Address); err == nil {
|
||||||
|
list[i].Address = normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
func TestAddWalletAddressWithNetworkNormalizesEvmAddressToLowercase(t *testing.T) {
|
||||||
@@ -81,3 +83,23 @@ func TestAddWalletAddressWithNetworkKeepsOriginalCaseForNonEvm(t *testing.T) {
|
|||||||
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
|
t.Fatalf("solana wallet address = %q, want %q", solRow.Address, solAddress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAddWalletAddressWithNetworkNormalizesTonAddressVariants(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
bounceable := "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"
|
||||||
|
addr := address.MustParseAddr(bounceable)
|
||||||
|
expected := addr.Bounce(false).String()
|
||||||
|
|
||||||
|
row, err := AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add raw ton wallet: %v", err)
|
||||||
|
}
|
||||||
|
if row.Address != expected {
|
||||||
|
t.Fatalf("stored TON address = %q, want %q", row.Address, expected)
|
||||||
|
}
|
||||||
|
if _, err = AddWalletAddressWithNetwork(mdb.NetworkTon, bounceable); err != constant.WalletAddressAlreadyExists {
|
||||||
|
t.Fatalf("add equivalent ton wallet error = %v, want already exists", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+25
-13
@@ -1,15 +1,24 @@
|
|||||||
package mdb
|
package mdb
|
||||||
|
|
||||||
const (
|
const (
|
||||||
StatusWaitPay = 1
|
StatusWaitPay = 1
|
||||||
StatusPaySuccess = 2
|
StatusPaySuccess = 2
|
||||||
StatusExpired = 3
|
StatusExpired = 3
|
||||||
|
// StatusWaitSelect is a placeholder order waiting for the payer to choose
|
||||||
|
// token+network or a hosted provider such as OkPay. It is not payable until
|
||||||
|
// switch-network moves it to StatusWaitPay.
|
||||||
|
StatusWaitSelect = 4
|
||||||
CallBackConfirmOk = 1
|
CallBackConfirmOk = 1
|
||||||
CallBackConfirmNo = 2
|
CallBackConfirmNo = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
// PaymentTypeEpay is the normalized special payment_type value. Orders
|
||||||
|
// matching it case-insensitively use the legacy EPay callback shape.
|
||||||
PaymentTypeEpay = "Epay"
|
PaymentTypeEpay = "Epay"
|
||||||
|
// PaymentTypeGmpay is the normalized default for GMPay-created orders.
|
||||||
|
// It uses the default GMPay JSON callback shape.
|
||||||
|
PaymentTypeGmpay = "Gmpay"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -19,9 +28,10 @@ const (
|
|||||||
// PaymentProviderOkPay means this concrete order record is settled through
|
// PaymentProviderOkPay means this concrete order record is settled through
|
||||||
// the third-party OkayPay/OkPay hosted checkout flow.
|
// the third-party OkayPay/OkPay hosted checkout flow.
|
||||||
//
|
//
|
||||||
// In the current design this is typically used by a switch-network-created
|
// It can be used by a status=4 parent completed in place on first
|
||||||
// child order, while the parent order keeps its original merchant-facing
|
// switch-network, or by a switch-network-created child order after the
|
||||||
// semantics and callback behavior.
|
// parent is already concrete. Merchant callback shape is still controlled by
|
||||||
|
// PaymentType, not by this provider value.
|
||||||
PaymentProviderOkPay = "okpay"
|
PaymentProviderOkPay = "okpay"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -36,21 +46,23 @@ type Orders struct {
|
|||||||
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
|
ReceiveAddress string `gorm:"column:receive_address" json:"receive_address" example:"TTestTronAddress001"`
|
||||||
Token string `gorm:"column:token" json:"token" example:"USDT"`
|
Token string `gorm:"column:token" json:"token" example:"USDT"`
|
||||||
Network string `gorm:"column:network" json:"network" example:"tron"`
|
Network string `gorm:"column:network" json:"network" example:"tron"`
|
||||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
||||||
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3" example:"1"`
|
Status int `gorm:"column:status;default:1" json:"status" enums:"1,2,3,4" example:"1"`
|
||||||
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
|
NotifyUrl string `gorm:"column:notify_url" json:"notify_url" example:"https://example.com/notify"`
|
||||||
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
|
RedirectUrl string `gorm:"column:redirect_url" json:"redirect_url" example:"https://example.com/success"`
|
||||||
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
|
Name string `gorm:"column:name" json:"name" example:"VIP月卡"`
|
||||||
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
|
CallbackNum int `gorm:"column:callback_num;default:0" json:"callback_num" example:"0"`
|
||||||
// 回调确认状态 1=回调成功 2=未回调/回调失败
|
// 回调确认状态 1=回调成功 2=未回调/回调失败
|
||||||
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
|
CallBackConfirm int `gorm:"column:callback_confirm;default:2" json:"callback_confirm" enums:"1,2" example:"2"`
|
||||||
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
|
IsSelected bool `gorm:"column:is_selected;default:false" json:"is_selected" example:"false"`
|
||||||
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
|
// PaymentType is not a channel selector. "Epay" changes callback format
|
||||||
|
// to legacy EPay case-insensitively; "Gmpay" uses GMPay JSON.
|
||||||
|
PaymentType string `gorm:"column:payment_type" json:"payment_type" example:"Epay"`
|
||||||
// PayProvider identifies how this specific order row is collected.
|
// PayProvider identifies how this specific order row is collected.
|
||||||
//
|
//
|
||||||
// Semantics:
|
// Semantics:
|
||||||
// - parent orders and regular chain child orders use on_chain
|
// - chain parent orders and regular chain child orders use on_chain
|
||||||
// - third-party hosted checkout child orders use their provider name
|
// - third-party hosted checkout rows use their provider name
|
||||||
// (for example okpay)
|
// (for example okpay)
|
||||||
//
|
//
|
||||||
// Existing rows default to on_chain for backward compatibility so upgrades
|
// Existing rows default to on_chain for backward compatibility so upgrades
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ProviderOrder stores provider-specific checkout data for one internal order
|
// ProviderOrder stores provider-specific checkout data for one internal order
|
||||||
// row. For OkPay this is expected to bind to the concrete child order created
|
// row. For OkPay this can bind either to a status=4 parent completed in place
|
||||||
// by switch-network, not to the parent merchant order.
|
// or to a concrete child order created by later switch-network calls.
|
||||||
//
|
//
|
||||||
// Binding rules:
|
// Binding rules:
|
||||||
// - trade_id always points to orders.trade_id
|
// - trade_id always points to orders.trade_id
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import "github.com/dromara/carbon/v2"
|
|||||||
const (
|
const (
|
||||||
RpcNodeTypeHttp = "http"
|
RpcNodeTypeHttp = "http"
|
||||||
RpcNodeTypeWs = "ws"
|
RpcNodeTypeWs = "ws"
|
||||||
|
RpcNodeTypeLite = "lite"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -24,8 +25,8 @@ const (
|
|||||||
type RpcNode struct {
|
type RpcNode struct {
|
||||||
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
|
Network string `gorm:"column:network;size:32;index:rpc_nodes_network_status_index,priority:1" json:"network" example:"tron"`
|
||||||
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
|
Url string `gorm:"column:url;size:512" json:"url" example:"https://api.trongrid.io"`
|
||||||
// 连接类型 http=HTTP请求 ws=WebSocket长连接
|
// 连接类型 http=HTTP请求 ws=WebSocket长连接 lite=TON liteserver配置
|
||||||
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws" example:"http"`
|
Type string `gorm:"column:type;size:16" json:"type" enums:"http,ws,lite" example:"http"`
|
||||||
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
|
Weight int `gorm:"column:weight;default:1" json:"weight" example:"1"`
|
||||||
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
|
ApiKey string `gorm:"column:api_key;size:255" json:"api_key" example:"your-api-key"`
|
||||||
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
Enabled bool `gorm:"column:enabled;default:true" json:"enabled" example:"true"`
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ package mdb
|
|||||||
// Groups: brand, rate, system, epay, okpay. Merchant credentials (pid +
|
// Groups: brand, rate, system, epay, okpay. Merchant credentials (pid +
|
||||||
// secret_key) live in the api_keys table; notification configs live in
|
// secret_key) live in the api_keys table; notification configs live in
|
||||||
// NotificationChannel.
|
// NotificationChannel.
|
||||||
// Default rows for the epay/okpay groups are seeded on first startup (see
|
// Default rows for the system/epay/okpay groups are seeded on first startup
|
||||||
// model/dao/mdb_table_init.go seedDefaultSettings). All other groups start
|
// (see model/dao/mdb_table_init.go seedDefaultSettings). Other groups start
|
||||||
// empty and fall back to hardcoded defaults until an admin sets them.
|
// empty and fall back to hardcoded defaults until an admin sets them. The
|
||||||
// Only exception: system.jwt_secret is auto-generated on first startup.
|
// system.jwt_secret key is auto-generated on first startup.
|
||||||
const (
|
const (
|
||||||
SettingGroupBrand = "brand"
|
SettingGroupBrand = "brand"
|
||||||
SettingGroupRate = "rate"
|
SettingGroupRate = "rate"
|
||||||
@@ -31,6 +31,7 @@ const (
|
|||||||
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
|
SettingKeyInitAdminPasswordChanged = "system.init_admin_password_changed"
|
||||||
SettingKeyOrderExpiration = "system.order_expiration_time"
|
SettingKeyOrderExpiration = "system.order_expiration_time"
|
||||||
SettingKeyAmountPrecision = "system.amount_precision"
|
SettingKeyAmountPrecision = "system.amount_precision"
|
||||||
|
SettingKeySystemLogLevel = "system.log_level"
|
||||||
SettingKeyBrandCheckoutName = "brand.checkout_name"
|
SettingKeyBrandCheckoutName = "brand.checkout_name"
|
||||||
SettingKeyBrandSiteName = "brand.site_name"
|
SettingKeyBrandSiteName = "brand.site_name"
|
||||||
SettingKeyBrandLogoUrl = "brand.logo_url"
|
SettingKeyBrandLogoUrl = "brand.logo_url"
|
||||||
@@ -62,6 +63,10 @@ const (
|
|||||||
SettingKeyOkPayAllowTokens = "okpay.allow_tokens"
|
SettingKeyOkPayAllowTokens = "okpay.allow_tokens"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SettingDefaultSystemLogLevel = "error"
|
||||||
|
)
|
||||||
|
|
||||||
type Setting struct {
|
type Setting struct {
|
||||||
Group string `gorm:"column:group;size:32;index:settings_group_index" json:"group" enums:"brand,rate,system,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_rate_list"`
|
Key string `gorm:"column:key;uniqueIndex:settings_key_uindex;size:128" json:"key" example:"rate.forced_rate_list"`
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const (
|
|||||||
NetworkBsc = "binance"
|
NetworkBsc = "binance"
|
||||||
NetworkPolygon = "polygon"
|
NetworkPolygon = "polygon"
|
||||||
NetworkPlasma = "plasma"
|
NetworkPlasma = "plasma"
|
||||||
|
NetworkTon = "ton"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -6,14 +6,18 @@ import "github.com/gookit/validate"
|
|||||||
type CreateTransactionRequest struct {
|
type CreateTransactionRequest struct {
|
||||||
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
|
OrderId string `json:"order_id" form:"order_id" validate:"required|maxLen:32" example:"ORD20260416001"`
|
||||||
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
|
Currency string `json:"currency" form:"currency" validate:"required" example:"cny"` // 法币 如:cny
|
||||||
Token string `json:"token" form:"token" validate:"required" example:"usdt"` // 币种 如:usdt
|
Token string `json:"token" form:"token" example:"usdt"` // 币种 如:usdt、ton;可与 network 同时缺省创建占位订单
|
||||||
Network string `json:"network" form:"network" validate:"required" example:"tron"` // 网络 如:tron
|
Network string `json:"network" form:"network" example:"tron"` // 网络 如:ton、tron;可与 token 同时缺省创建占位订单
|
||||||
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
|
Amount float64 `json:"amount" form:"amount" validate:"required|isFloat|gt:0.01" example:"100.00"`
|
||||||
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
|
NotifyUrl string `json:"notify_url" form:"notify_url" validate:"required" example:"https://example.com/notify"`
|
||||||
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
|
Signature string `json:"signature" form:"signature" validate:"required" example:"a1b2c3d4e5f6..."`
|
||||||
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
|
RedirectUrl string `json:"redirect_url" form:"redirect_url" example:"https://example.com/success"`
|
||||||
Name string `json:"name" form:"name" example:"VIP月卡"`
|
Name string `json:"name" form:"name" example:"VIP月卡"`
|
||||||
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
|
// PaymentType is a compatibility flag, not a gateway selector. Only
|
||||||
|
// "Epay" (case-insensitive) switches callback format to legacy EPay;
|
||||||
|
// empty or any other value is stored as "Gmpay" and uses GMPay JSON.
|
||||||
|
// It is optional for GMPay, but must be included in the signature when sent.
|
||||||
|
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r CreateTransactionRequest) Translates() map[string]string {
|
func (r CreateTransactionRequest) Translates() map[string]string {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ type CreateTransactionResponse struct {
|
|||||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数
|
||||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
||||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
||||||
|
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
||||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 收银台地址
|
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 收银台地址
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
package response
|
package response
|
||||||
|
|
||||||
type CheckoutCounterResponse struct {
|
type CheckoutCounterResponse struct {
|
||||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||||
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
|
Amount float64 `json:"amount" example:"100.0000"` // 订单金额,按 system.amount_precision 保留小数 法币金额
|
||||||
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额,按 system.amount_precision 保留小数 加密货币金额
|
ActualAmount float64 `json:"actual_amount" example:"14.2857"` // 订单实际需要支付的金额;status=4 占位订单返回 0
|
||||||
Token string `json:"token" example:"USDT"` // 所属币种 TRX USDT......
|
Token string `json:"token" example:"USDT"` // 所属币种;status=4 占位订单为空
|
||||||
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
|
Currency string `json:"currency" example:"CNY"` // 法币币种 CNY USD ...
|
||||||
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址
|
ReceiveAddress string `json:"receive_address" example:"TTestTronAddress001"` // 收款钱包地址;status=4 占位订单为空
|
||||||
Network string `json:"network" example:"tron"` // 网络 TRON ETH ...
|
Network string `json:"network" example:"tron"` // 网络 TRON ETH;status=4 占位订单为空
|
||||||
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
Status int `json:"status" enums:"1,2,3,4" example:"1"` // 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种;status=4 时前端应引导选择链上 token/network 或 OkPay
|
||||||
|
PaymentType string `json:"payment_type" enums:"gmpay,epay" example:"gmpay"` // 支付接入类型;底层 Epay/Gmpay 转为小写 epay/gmpay 返回
|
||||||
|
ExpirationTime int64 `json:"expiration_time" example:"1713264600"` // 过期时间 时间戳
|
||||||
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
|
RedirectUrl string `json:"redirect_url" example:"https://example.com/success"`
|
||||||
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单通常为本地收银台,OkPay 子订单为第三方 payLink
|
PaymentUrl string `json:"payment_url" example:"https://pay.example.com/checkout/3nQ9pL2xV7sK1mR8cT4yB_aZ"` // 支付链接;链上订单为空,OkPay 订单为第三方 payLink
|
||||||
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
CreatedAt int64 `json:"created_at" example:"1713264000"` // 订单创建时间 时间戳
|
||||||
IsSelected bool `json:"is_selected" example:"false"`
|
IsSelected bool `json:"is_selected" example:"false"` // 是否已选择当前支付方式;status=4 占位订单和刚补全的占位父单为 false
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckStatusResponse struct {
|
type CheckStatusResponse struct {
|
||||||
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
TradeId string `json:"trade_id" example:"3nQ9pL2xV7sK1mR8cT4yB_aZ"` // epusdt订单号
|
||||||
// 订单状态 1=等待支付 2=支付成功 3=已过期
|
// 订单状态 1=等待支付 2=支付成功 3=已过期 4=等待选择支付网络/币种
|
||||||
Status int `json:"status" enums:"1,2,3" example:"1"`
|
Status int `json:"status" enums:"1,2,3,4" example:"1"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManualPaymentResponse struct {
|
type ManualPaymentResponse struct {
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ type NetworkTokenSupport struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type EpayPublicConfig struct {
|
type EpayPublicConfig struct {
|
||||||
DefaultToken string `json:"default_token" example:"usdt"`
|
DefaultToken string `json:"default_token" example:""` // EPay default token; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
|
||||||
DefaultCurrency string `json:"default_currency" example:"cny"`
|
DefaultCurrency string `json:"default_currency" example:"cny"` // EPay default fiat currency; falls back to cny when unset.
|
||||||
DefaultNetwork string `json:"default_network" example:"tron"`
|
DefaultNetwork string `json:"default_network" example:""` // EPay default network; empty means submit.php can create a status=4 placeholder when request token/network are also absent.
|
||||||
}
|
}
|
||||||
|
|
||||||
type SitePublicConfig struct {
|
type SitePublicConfig struct {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
tron "github.com/GMWalletApp/epusdt/crypto"
|
tron "github.com/GMWalletApp/epusdt/crypto"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/GMWalletApp/epusdt/util/math"
|
"github.com/GMWalletApp/epusdt/util/math"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
@@ -23,6 +24,10 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
"github.com/shopspring/decimal"
|
"github.com/shopspring/decimal"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
|
"github.com/xssnick/tonutils-go/tlb"
|
||||||
|
"github.com/xssnick/tonutils-go/ton"
|
||||||
|
"github.com/xssnick/tonutils-go/ton/jetton"
|
||||||
)
|
)
|
||||||
|
|
||||||
const manualVerifyRequestTimeout = 15 * time.Second
|
const manualVerifyRequestTimeout = 15 * time.Second
|
||||||
@@ -82,6 +87,8 @@ func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID str
|
|||||||
canonicalTxID, err = validateManualTronPayment(order, txID)
|
canonicalTxID, err = validateManualTronPayment(order, txID)
|
||||||
case mdb.NetworkSolana:
|
case mdb.NetworkSolana:
|
||||||
canonicalTxID, err = validateManualSolanaPayment(order, txID)
|
canonicalTxID, err = validateManualSolanaPayment(order, txID)
|
||||||
|
case mdb.NetworkTon:
|
||||||
|
canonicalTxID, err = validateManualTonPayment(order, txID)
|
||||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||||
canonicalTxID, err = validateManualEvmPayment(order, txID)
|
canonicalTxID, err = validateManualEvmPayment(order, txID)
|
||||||
default:
|
default:
|
||||||
@@ -636,6 +643,245 @@ func tronPostJSON(baseURL, apiKey, path string, body interface{}, out interface{
|
|||||||
return json.Unmarshal(raw, out)
|
return json.Unmarshal(raw, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type manualTonTxRef struct {
|
||||||
|
ReceiveRaw string
|
||||||
|
LT uint64
|
||||||
|
HashHex string
|
||||||
|
HasLT bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualTonPayment(order *mdb.Orders, txID string) (string, error) {
|
||||||
|
ref, err := parseManualTonTxRef(txID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
orderAddr, err := addressutil.ParseTonMainnetAddress(order.ReceiveAddress)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("invalid order receive address: %w", err)
|
||||||
|
}
|
||||||
|
if ref.ReceiveRaw != "" && ref.ReceiveRaw != orderAddr.Raw {
|
||||||
|
return "", fmt.Errorf("transaction recipient mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkTon, mdb.RpcNodeTypeLite)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTon, mdb.RpcNodeTypeLite)
|
||||||
|
}
|
||||||
|
|
||||||
|
var verifyErrors []string
|
||||||
|
for _, node := range nodes {
|
||||||
|
api, closeFn, err := dialManualTonClient(node)
|
||||||
|
if err != nil {
|
||||||
|
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: connect: %v", manualRpcNodeLabel(node), err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
canonicalID, err := validateManualTonPaymentWithAPI(context.Background(), api, order, orderAddr.Address, ref)
|
||||||
|
closeFn()
|
||||||
|
if err != nil {
|
||||||
|
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", manualRpcNodeLabel(node), err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return canonicalID, nil
|
||||||
|
}
|
||||||
|
if len(verifyErrors) > 0 {
|
||||||
|
return "", fmt.Errorf("manual TON verification failed: %s", strings.Join(verifyErrors, "; "))
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTon, mdb.RpcNodeTypeLite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseManualTonTxRef(input string) (manualTonTxRef, error) {
|
||||||
|
raw := strings.TrimSpace(input)
|
||||||
|
if raw == "" {
|
||||||
|
return manualTonTxRef{}, fmt.Errorf("block_transaction_id is required")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(strings.ToLower(raw), "ton:") {
|
||||||
|
body := raw[4:]
|
||||||
|
last := strings.LastIndex(body, ":")
|
||||||
|
if last <= 0 || last == len(body)-1 {
|
||||||
|
return manualTonTxRef{}, fmt.Errorf("invalid ton canonical transaction id")
|
||||||
|
}
|
||||||
|
hashHex, err := normalizeTonTxHashHex(body[last+1:])
|
||||||
|
if err != nil {
|
||||||
|
return manualTonTxRef{}, err
|
||||||
|
}
|
||||||
|
left := body[:last]
|
||||||
|
mid := strings.LastIndex(left, ":")
|
||||||
|
if mid <= 0 || mid == len(left)-1 {
|
||||||
|
return manualTonTxRef{}, fmt.Errorf("invalid ton canonical transaction id")
|
||||||
|
}
|
||||||
|
lt, err := parseUint64Strict(left[mid+1:])
|
||||||
|
if err != nil {
|
||||||
|
return manualTonTxRef{}, fmt.Errorf("invalid ton transaction lt")
|
||||||
|
}
|
||||||
|
receiveRaw, err := addressutil.TonRawAddressKey(left[:mid])
|
||||||
|
if err != nil {
|
||||||
|
return manualTonTxRef{}, fmt.Errorf("invalid ton canonical recipient: %w", err)
|
||||||
|
}
|
||||||
|
return manualTonTxRef{ReceiveRaw: receiveRaw, LT: lt, HashHex: hashHex, HasLT: true}, nil
|
||||||
|
}
|
||||||
|
if idx := strings.Index(raw, ":"); idx > 0 {
|
||||||
|
lt, err := parseUint64Strict(raw[:idx])
|
||||||
|
if err != nil {
|
||||||
|
return manualTonTxRef{}, fmt.Errorf("invalid ton transaction lt")
|
||||||
|
}
|
||||||
|
hashHex, err := normalizeTonTxHashHex(raw[idx+1:])
|
||||||
|
if err != nil {
|
||||||
|
return manualTonTxRef{}, err
|
||||||
|
}
|
||||||
|
return manualTonTxRef{LT: lt, HashHex: hashHex, HasLT: true}, nil
|
||||||
|
}
|
||||||
|
hashHex, err := normalizeTonTxHashHex(raw)
|
||||||
|
if err != nil {
|
||||||
|
return manualTonTxRef{}, err
|
||||||
|
}
|
||||||
|
return manualTonTxRef{HashHex: hashHex}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTonTxHashHex(raw string) (string, error) {
|
||||||
|
hashHex := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(raw, "0x"), "0X")))
|
||||||
|
if len(hashHex) != 64 {
|
||||||
|
return "", fmt.Errorf("invalid ton transaction hash length")
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(hashHex); err != nil {
|
||||||
|
return "", fmt.Errorf("invalid ton transaction hash")
|
||||||
|
}
|
||||||
|
return hashHex, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUint64Strict(raw string) (uint64, error) {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return 0, fmt.Errorf("empty integer")
|
||||||
|
}
|
||||||
|
var out uint64
|
||||||
|
for _, ch := range raw {
|
||||||
|
if ch < '0' || ch > '9' {
|
||||||
|
return 0, fmt.Errorf("invalid integer")
|
||||||
|
}
|
||||||
|
out = out*10 + uint64(ch-'0')
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialManualTonClient(node mdb.RpcNode) (ton.APIClientWrapped, func(), error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), manualVerifyRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return ConnectTonLiteAPI(ctx, node.Url, manualVerifyRequestTimeout, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualTonPaymentWithAPI(ctx context.Context, api ton.APIClientWrapped, order *mdb.Orders, receive *address.Address, ref manualTonTxRef) (string, error) {
|
||||||
|
master, err := api.CurrentMasterchainInfo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fetch current masterchain: %w", err)
|
||||||
|
}
|
||||||
|
txs, err := listManualTonCandidateTransactions(ctx, api, master, receive, ref)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("list ton account transactions: %w", err)
|
||||||
|
}
|
||||||
|
if len(txs) == 0 {
|
||||||
|
return "", fmt.Errorf("transaction not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := manualTonTokenState(ctx, api, master, receive)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return validateManualTonPaymentCandidates(order, receive, ref, txs, state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualTonPaymentCandidates(order *mdb.Orders, receive *address.Address, ref manualTonTxRef, txs []*tlb.Transaction, state *manualTonState) (string, error) {
|
||||||
|
if state == nil {
|
||||||
|
state = &manualTonState{jettonWallets: make(map[string]mdb.ChainToken)}
|
||||||
|
}
|
||||||
|
var matched []*TonObservedTransfer
|
||||||
|
for _, tx := range txs {
|
||||||
|
if tx == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
hashHex := hex.EncodeToString(tx.Hash)
|
||||||
|
if !strings.EqualFold(hashHex, ref.HashHex) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ref.HasLT && tx.LT != ref.LT {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
transfer, err := ParseTonInboundTransfer(tx, receive, state.nativeToken, state.jettonWallets)
|
||||||
|
if err != nil || transfer == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err = EnsureTonTransferMatchesOrder(order, transfer); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
matched = append(matched, transfer)
|
||||||
|
}
|
||||||
|
if len(matched) == 0 {
|
||||||
|
return "", fmt.Errorf("matching ton transfer to order address not found")
|
||||||
|
}
|
||||||
|
if len(matched) > 1 && !ref.HasLT {
|
||||||
|
return "", fmt.Errorf("ton transaction hash matched multiple recent transactions; submit canonical id or lt:hash")
|
||||||
|
}
|
||||||
|
receiveRaw := addressutil.TonRawAddressObjectKey(receive)
|
||||||
|
return TonCanonicalBlockTransactionID(receiveRaw, matched[0].LT, matched[0].TxHashHex), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func listManualTonCandidateTransactions(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, receive *address.Address, ref manualTonTxRef) ([]*tlb.Transaction, error) {
|
||||||
|
waiter := api.WaitForBlock(master.SeqNo)
|
||||||
|
if ref.HasLT {
|
||||||
|
hashBytes, err := hex.DecodeString(ref.HashHex)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return waiter.ListTransactions(ctx, receive, 1, ref.LT, hashBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
account, err := waiter.GetAccount(ctx, master, receive)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch ton account state: %w", err)
|
||||||
|
}
|
||||||
|
if account == nil || account.LastTxLT == 0 || len(account.LastTxHash) == 0 {
|
||||||
|
return nil, fmt.Errorf("transaction not found")
|
||||||
|
}
|
||||||
|
return waiter.ListTransactions(ctx, receive, 100, account.LastTxLT, account.LastTxHash)
|
||||||
|
}
|
||||||
|
|
||||||
|
type manualTonState struct {
|
||||||
|
nativeToken *mdb.ChainToken
|
||||||
|
jettonWallets map[string]mdb.ChainToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func manualTonTokenState(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, receive *address.Address) (*manualTonState, error) {
|
||||||
|
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTon)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state := &manualTonState{jettonWallets: make(map[string]mdb.ChainToken)}
|
||||||
|
for i := range tokens {
|
||||||
|
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
|
||||||
|
contract := strings.TrimSpace(tokens[i].ContractAddress)
|
||||||
|
if sym == TonNativeSymbol && contract == "" {
|
||||||
|
token := tokens[i]
|
||||||
|
state.nativeToken = &token
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if contract == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
masterAddr, err := addressutil.ParseTonMainnetAddress(contract)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wallet, err := jetton.NewJettonMasterClient(api, masterAddr.Address).GetJettonWalletAtBlock(ctx, receive, master)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
state.jettonWallets[addressutil.TonRawAddressObjectKey(wallet.Address())] = tokens[i]
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) {
|
func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) {
|
||||||
sig = strings.TrimSpace(sig)
|
sig = strings.TrimSpace(sig)
|
||||||
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
nodes, err := data.ListManualPaymentRpcCandidates(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -16,11 +18,33 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
"github.com/dromara/carbon/v2"
|
"github.com/dromara/carbon/v2"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
|
"github.com/xssnick/tonutils-go/tlb"
|
||||||
|
"github.com/xssnick/tonutils-go/ton"
|
||||||
|
"github.com/xssnick/tonutils-go/ton/jetton"
|
||||||
|
"github.com/xssnick/tonutils-go/tvm/cell"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func upsertTestChainToken(t *testing.T, token mdb.ChainToken) {
|
||||||
|
t.Helper()
|
||||||
|
if err := dao.Mdb.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "network"}, {Name: "symbol"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"contract_address",
|
||||||
|
"decimals",
|
||||||
|
"enabled",
|
||||||
|
"min_amount",
|
||||||
|
}),
|
||||||
|
}).Create(&token).Error; err != nil {
|
||||||
|
t.Fatalf("upsert token: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestManualVerifyEvmHashAcceptsOptional0x(t *testing.T) {
|
func TestManualVerifyEvmHashAcceptsOptional0x(t *testing.T) {
|
||||||
hash := strings.Repeat("a", 64)
|
hash := strings.Repeat("a", 64)
|
||||||
if !isEvmHash(hash) {
|
if !isEvmHash(hash) {
|
||||||
@@ -89,6 +113,215 @@ func TestManualVerifyNormalizeTronTxIDAcceptsOptional0x(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseManualTonTxRefAcceptsCanonicalLTAndHashOnly(t *testing.T) {
|
||||||
|
receiveRaw := "0:ba295e33b3c4c9b5265aa4ead1166a92931ce9abea120a8c5e91044a1257f89c"
|
||||||
|
hash := strings.Repeat("a", 64)
|
||||||
|
|
||||||
|
ref, err := parseManualTonTxRef("ton:" + receiveRaw + ":123:" + strings.ToUpper(hash))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse canonical TON tx ref: %v", err)
|
||||||
|
}
|
||||||
|
if ref.ReceiveRaw != receiveRaw || ref.LT != 123 || ref.HashHex != hash || !ref.HasLT {
|
||||||
|
t.Fatalf("canonical ref = %#v", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err = parseManualTonTxRef("456:0X" + strings.ToUpper(hash))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse lt:hash TON tx ref: %v", err)
|
||||||
|
}
|
||||||
|
if ref.ReceiveRaw != "" || ref.LT != 456 || ref.HashHex != hash || !ref.HasLT {
|
||||||
|
t.Fatalf("lt:hash ref = %#v", ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
ref, err = parseManualTonTxRef("0X" + strings.ToUpper(hash))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse hash-only TON tx ref: %v", err)
|
||||||
|
}
|
||||||
|
if ref.ReceiveRaw != "" || ref.LT != 0 || ref.HashHex != hash || ref.HasLT {
|
||||||
|
t.Fatalf("hash-only ref = %#v", ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeManualTonAPI struct {
|
||||||
|
ton.APIClientWrapped
|
||||||
|
master *ton.BlockIDExt
|
||||||
|
account *tlb.Account
|
||||||
|
txs []*tlb.Transaction
|
||||||
|
getAccountCalls int
|
||||||
|
listCalls []manualTonListCall
|
||||||
|
}
|
||||||
|
|
||||||
|
type manualTonListCall struct {
|
||||||
|
limit uint32
|
||||||
|
lt uint64
|
||||||
|
hashHex string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeManualTonAPI) CurrentMasterchainInfo(context.Context) (*ton.BlockIDExt, error) {
|
||||||
|
return f.master, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeManualTonAPI) WaitForBlock(uint32) ton.APIClientWrapped {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeManualTonAPI) GetAccount(context.Context, *ton.BlockIDExt, *address.Address) (*tlb.Account, error) {
|
||||||
|
f.getAccountCalls++
|
||||||
|
if f.account == nil {
|
||||||
|
return &tlb.Account{}, nil
|
||||||
|
}
|
||||||
|
return f.account, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeManualTonAPI) ListTransactions(_ context.Context, _ *address.Address, limit uint32, lt uint64, txHash []byte) ([]*tlb.Transaction, error) {
|
||||||
|
f.listCalls = append(f.listCalls, manualTonListCall{
|
||||||
|
limit: limit,
|
||||||
|
lt: lt,
|
||||||
|
hashHex: hex.EncodeToString(txHash),
|
||||||
|
})
|
||||||
|
return f.txs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManualTonPaymentWithAPINativeTON(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Model(&mdb.ChainToken{}).
|
||||||
|
Where("network = ? AND symbol = ?", mdb.NetworkTon, "USDT").
|
||||||
|
Update("enabled", false).Error; err != nil {
|
||||||
|
t.Fatalf("disable TON USDT token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x66}, 32)).Bounce(false).Testnet(false)
|
||||||
|
tx := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
||||||
|
order := &mdb.Orders{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
Token: "TON",
|
||||||
|
ActualAmount: 1.23,
|
||||||
|
ReceiveAddress: receive.Bounce(false).String(),
|
||||||
|
}
|
||||||
|
api := &fakeManualTonAPI{
|
||||||
|
master: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: -0x8000000000000000, SeqNo: 9},
|
||||||
|
txs: []*tlb.Transaction{tx},
|
||||||
|
}
|
||||||
|
ref := manualTonTxRef{LT: tx.LT, HashHex: hex.EncodeToString(tx.Hash), HasLT: true}
|
||||||
|
|
||||||
|
got, err := validateManualTonPaymentWithAPI(context.Background(), api, order, receive, ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateManualTonPaymentWithAPI(): %v", err)
|
||||||
|
}
|
||||||
|
if api.getAccountCalls != 0 {
|
||||||
|
t.Fatalf("exact TON ref fetched account state %d times, want 0", api.getAccountCalls)
|
||||||
|
}
|
||||||
|
if len(api.listCalls) != 1 {
|
||||||
|
t.Fatalf("ListTransactions calls = %#v, want one exact lookup", api.listCalls)
|
||||||
|
}
|
||||||
|
if call := api.listCalls[0]; call.limit != 1 || call.lt != tx.LT || call.hashHex != hex.EncodeToString(tx.Hash) {
|
||||||
|
t.Fatalf("ListTransactions exact call = %#v, want limit=1 lt/hash from ref", call)
|
||||||
|
}
|
||||||
|
want := TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash))
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("canonical id = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManualTonPaymentCandidatesUSDTJetton(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x68}, 32)).Bounce(false).Testnet(false)
|
||||||
|
jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x69}, 32)).Bounce(false).Testnet(false)
|
||||||
|
body, err := tlb.ToCell(jetton.TransferNotification{
|
||||||
|
QueryID: 7,
|
||||||
|
Amount: tlb.MustFromNano(big.NewInt(1_000_000), 6),
|
||||||
|
Sender: sender,
|
||||||
|
ForwardPayload: cell.BeginCell().EndCell(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build jetton notification body: %v", err)
|
||||||
|
}
|
||||||
|
tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body)
|
||||||
|
order := &mdb.Orders{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
Token: "USDT",
|
||||||
|
ActualAmount: 1,
|
||||||
|
ReceiveAddress: receive.Bounce(false).String(),
|
||||||
|
}
|
||||||
|
state := &manualTonState{
|
||||||
|
jettonWallets: map[string]mdb.ChainToken{
|
||||||
|
addressutil.TonRawAddressObjectKey(jettonWallet): {
|
||||||
|
BaseModel: mdb.BaseModel{ID: 2},
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
Symbol: "USDT",
|
||||||
|
Decimals: 6,
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := validateManualTonPaymentCandidates(order, receive, manualTonTxRef{
|
||||||
|
LT: tx.LT,
|
||||||
|
HashHex: hex.EncodeToString(tx.Hash),
|
||||||
|
HasLT: true,
|
||||||
|
}, []*tlb.Transaction{tx}, state)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateManualTonPaymentCandidates(): %v", err)
|
||||||
|
}
|
||||||
|
want := TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash))
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("canonical id = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateManualTonPaymentWithAPIRejectsAmbiguousHashOnly(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Model(&mdb.ChainToken{}).
|
||||||
|
Where("network = ? AND symbol = ?", mdb.NetworkTon, "USDT").
|
||||||
|
Update("enabled", false).Error; err != nil {
|
||||||
|
t.Fatalf("disable TON USDT token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x67}, 32)).Bounce(false).Testnet(false)
|
||||||
|
tx1 := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
||||||
|
tx2 := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), nil)
|
||||||
|
tx2.LT = tx1.LT + 1
|
||||||
|
order := &mdb.Orders{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 1, CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
Token: "TON",
|
||||||
|
ActualAmount: 1.23,
|
||||||
|
ReceiveAddress: receive.Bounce(false).String(),
|
||||||
|
}
|
||||||
|
api := &fakeManualTonAPI{
|
||||||
|
master: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: -0x8000000000000000, SeqNo: 9},
|
||||||
|
account: &tlb.Account{
|
||||||
|
LastTxLT: tx2.LT,
|
||||||
|
LastTxHash: tx2.Hash,
|
||||||
|
},
|
||||||
|
txs: []*tlb.Transaction{tx1, tx2},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := validateManualTonPaymentWithAPI(context.Background(), api, order, receive, manualTonTxRef{
|
||||||
|
HashHex: hex.EncodeToString(tx1.Hash),
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "matched multiple") {
|
||||||
|
t.Fatalf("validate ambiguous hash-only error = %v, want multiple-match error", err)
|
||||||
|
}
|
||||||
|
if api.getAccountCalls != 1 {
|
||||||
|
t.Fatalf("hash-only TON ref fetched account state %d times, want 1", api.getAccountCalls)
|
||||||
|
}
|
||||||
|
if len(api.listCalls) != 1 {
|
||||||
|
t.Fatalf("ListTransactions calls = %#v, want one recent lookup", api.listCalls)
|
||||||
|
}
|
||||||
|
if call := api.listCalls[0]; call.limit != 100 || call.lt != tx2.LT || call.hashHex != hex.EncodeToString(tx2.Hash) {
|
||||||
|
t.Fatalf("ListTransactions recent call = %#v, want account last tx cursor", call)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) {
|
func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -369,15 +602,13 @@ func TestManualVerifyTronTRC20UsesTransferEvent(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("recipient address: %v", err)
|
t.Fatalf("recipient address: %v", err)
|
||||||
}
|
}
|
||||||
if err = dao.Mdb.Create(&mdb.ChainToken{
|
upsertTestChainToken(t, mdb.ChainToken{
|
||||||
Network: mdb.NetworkTron,
|
Network: mdb.NetworkTron,
|
||||||
Symbol: "USDT",
|
Symbol: "USDT",
|
||||||
ContractAddress: contractAddress,
|
ContractAddress: contractAddress,
|
||||||
Decimals: 6,
|
Decimals: 6,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
}).Error; err != nil {
|
})
|
||||||
t.Fatalf("create token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rawAmount := big.NewInt(1230000)
|
rawAmount := big.NewInt(1230000)
|
||||||
tx := manualTronTransactionFromCallData(t, contractHex, recipientHex, rawAmount)
|
tx := manualTronTransactionFromCallData(t, contractHex, recipientHex, rawAmount)
|
||||||
@@ -491,15 +722,13 @@ func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
|
|||||||
}).Error; err != nil {
|
}).Error; err != nil {
|
||||||
t.Fatalf("create tron rpc node: %v", err)
|
t.Fatalf("create tron rpc node: %v", err)
|
||||||
}
|
}
|
||||||
if err = dao.Mdb.Create(&mdb.ChainToken{
|
upsertTestChainToken(t, mdb.ChainToken{
|
||||||
Network: mdb.NetworkTron,
|
Network: mdb.NetworkTron,
|
||||||
Symbol: "USDT",
|
Symbol: "USDT",
|
||||||
ContractAddress: contractAddress,
|
ContractAddress: contractAddress,
|
||||||
Decimals: 6,
|
Decimals: 6,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
}).Error; err != nil {
|
})
|
||||||
t.Fatalf("create token: %v", err)
|
|
||||||
}
|
|
||||||
order := &mdb.Orders{
|
order := &mdb.Orders{
|
||||||
TradeId: "manual-tron-http-flow",
|
TradeId: "manual-tron-http-flow",
|
||||||
OrderId: "manual-tron-http-flow",
|
OrderId: "manual-tron-http-flow",
|
||||||
|
|||||||
@@ -102,6 +102,79 @@ func TestHandleOkPayNotifySendsPaymentNotificationOnSuccess(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHandleOkPayNotifySettlesPlaceholderParent(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
const (
|
||||||
|
shopID = "okpay-shop-placeholder"
|
||||||
|
shopToken = "okpay-shop-placeholder-token"
|
||||||
|
parentTradeID = "okpay-placeholder-parent-001"
|
||||||
|
providerOrderID = "okpay-placeholder-provider-001"
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, shopID, mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay shop id: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, shopToken, mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay shop token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parent := &mdb.Orders{
|
||||||
|
TradeId: parentTradeID,
|
||||||
|
OrderId: "merchant-okpay-placeholder-parent-001",
|
||||||
|
Amount: 1,
|
||||||
|
Currency: "CNY",
|
||||||
|
ActualAmount: 0.15,
|
||||||
|
ReceiveAddress: "OKPAY",
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.PaymentProviderOkPay,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
IsSelected: true,
|
||||||
|
PaymentType: mdb.PaymentTypeGmpay,
|
||||||
|
PayProvider: mdb.PaymentProviderOkPay,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(parent).Error; err != nil {
|
||||||
|
t.Fatalf("seed parent order: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&mdb.ProviderOrder{
|
||||||
|
TradeId: parentTradeID,
|
||||||
|
Provider: mdb.PaymentProviderOkPay,
|
||||||
|
ProviderOrderID: providerOrderID,
|
||||||
|
Amount: 0.15,
|
||||||
|
Coin: "USDT",
|
||||||
|
Status: mdb.ProviderOrderStatusPending,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed provider order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
form := okPayNotifyTestForm(shopID, shopToken, providerOrderID, parentTradeID, "0.15000000", "USDT")
|
||||||
|
if err := HandleOkPayNotify(form, "placeholder-okpay-form"); err != nil {
|
||||||
|
t.Fatalf("handle okpay notify: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reloadedParent, err := data.GetOrderInfoByTradeId(parentTradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload parent: %v", err)
|
||||||
|
}
|
||||||
|
if reloadedParent.Status != mdb.StatusPaySuccess {
|
||||||
|
t.Fatalf("parent status = %d, want %d", reloadedParent.Status, mdb.StatusPaySuccess)
|
||||||
|
}
|
||||||
|
if reloadedParent.PayBySubId != 0 {
|
||||||
|
t.Fatalf("parent pay_by_sub_id = %d, want 0", reloadedParent.PayBySubId)
|
||||||
|
}
|
||||||
|
if reloadedParent.Token != "USDT" || reloadedParent.Network != mdb.PaymentProviderOkPay || reloadedParent.ReceiveAddress != "OKPAY" || reloadedParent.ActualAmount != 0.15 {
|
||||||
|
t.Fatalf("okpay parent fields changed: token=%q network=%q address=%q actual=%v", reloadedParent.Token, reloadedParent.Network, reloadedParent.ReceiveAddress, reloadedParent.ActualAmount)
|
||||||
|
}
|
||||||
|
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(parentTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload provider row: %v", err)
|
||||||
|
}
|
||||||
|
if providerRow.Status != mdb.ProviderOrderStatusPaid {
|
||||||
|
t.Fatalf("provider status = %q, want %q", providerRow.Status, mdb.ProviderOrderStatusPaid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func okPayNotifyTestForm(shopID, shopToken, providerOrderID, tradeID, amount, coin string) map[string]string {
|
func okPayNotifyTestForm(shopID, shopToken, providerOrderID, tradeID, amount, coin string) map[string]string {
|
||||||
form := map[string]string{
|
form := map[string]string{
|
||||||
"code": "200",
|
"code": "200",
|
||||||
|
|||||||
@@ -14,6 +14,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/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/GMWalletApp/epusdt/util/math"
|
"github.com/GMWalletApp/epusdt/util/math"
|
||||||
@@ -48,40 +49,71 @@ func normalizeOrderAddressByNetwork(network, address string) string {
|
|||||||
switch network {
|
switch network {
|
||||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||||
return strings.ToLower(address)
|
return strings.ToLower(address)
|
||||||
|
case mdb.NetworkTon:
|
||||||
|
if normalized, err := addressutil.NormalizeTonAddress(address); err == nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return address
|
||||||
default:
|
default:
|
||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ensureEnabledOrderAsset(network, token string) (*mdb.ChainToken, error) {
|
||||||
|
tokenRow, err := data.GetEnabledChainTokenBySymbol(network, token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if tokenRow == nil || tokenRow.ID == 0 {
|
||||||
|
return nil, constant.SupportedAssetNotFound
|
||||||
|
}
|
||||||
|
return tokenRow, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateCreateTransactionTokenNetwork(token, network string) error {
|
||||||
|
if (token == "") != (network == "") {
|
||||||
|
return constant.ParamsMarshalErr
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCreateTransactionResponse(order *mdb.Orders) *response.CreateTransactionResponse {
|
||||||
|
return &response.CreateTransactionResponse{
|
||||||
|
TradeId: order.TradeId,
|
||||||
|
OrderId: order.OrderId,
|
||||||
|
Amount: order.Amount,
|
||||||
|
Currency: order.Currency,
|
||||||
|
ActualAmount: order.ActualAmount,
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
Token: order.Token,
|
||||||
|
Status: order.Status,
|
||||||
|
ExpirationTime: carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp(),
|
||||||
|
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// CreateTransaction creates a new payment order.
|
// CreateTransaction creates a new payment order.
|
||||||
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
||||||
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||||
network := strings.ToLower(strings.TrimSpace(req.Network))
|
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||||
|
if err := validateCreateTransactionTokenNetwork(token, network); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
notifyURL := strings.TrimSpace(req.NotifyUrl)
|
notifyURL := strings.TrimSpace(req.NotifyUrl)
|
||||||
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
|
if err := security.ValidatePublicHTTPURL(notifyURL); err != nil {
|
||||||
return nil, constant.NotifyURLErr
|
return nil, constant.NotifyURLErr
|
||||||
}
|
}
|
||||||
|
paymentType := mdb.PaymentTypeGmpay
|
||||||
|
if strings.EqualFold(req.PaymentType, mdb.PaymentTypeEpay) {
|
||||||
|
paymentType = mdb.PaymentTypeEpay
|
||||||
|
}
|
||||||
|
|
||||||
gCreateTransactionLock.Lock()
|
gCreateTransactionLock.Lock()
|
||||||
defer gCreateTransactionLock.Unlock()
|
defer gCreateTransactionLock.Unlock()
|
||||||
|
|
||||||
amountPrecision := data.GetAmountPrecision()
|
amountPrecision := data.GetAmountPrecision()
|
||||||
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
|
payAmount := math.MustParsePrecFloat64(req.Amount, amountPrecision)
|
||||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
|
||||||
if rate <= 0 {
|
|
||||||
return nil, constant.RateAmountErr
|
|
||||||
}
|
|
||||||
|
|
||||||
decimalPayAmount := decimal.NewFromFloat(payAmount)
|
|
||||||
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
|
||||||
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
|
|
||||||
return nil, constant.PayAmountErr
|
|
||||||
}
|
|
||||||
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
|
||||||
return nil, constant.PayAmountErr
|
|
||||||
}
|
|
||||||
|
|
||||||
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
|
exist, err := data.GetOrderInfoByOrderId(req.OrderId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -90,9 +122,49 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
return nil, constant.OrderAlreadyExists
|
return nil, constant.OrderAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
decimalPayAmount := decimal.NewFromFloat(payAmount)
|
||||||
|
if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 {
|
||||||
|
return nil, constant.PayAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" && network == "" {
|
||||||
|
tradeID := GenerateCode()
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: tradeID,
|
||||||
|
OrderId: req.OrderId,
|
||||||
|
Amount: payAmount,
|
||||||
|
Currency: currency,
|
||||||
|
Status: mdb.StatusWaitSelect,
|
||||||
|
NotifyUrl: notifyURL,
|
||||||
|
RedirectUrl: req.RedirectUrl,
|
||||||
|
Name: req.Name,
|
||||||
|
PaymentType: paymentType,
|
||||||
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
|
ApiKeyID: apiKeyID(apiKey),
|
||||||
|
}
|
||||||
|
if err = data.CreateOrderWithTransaction(dao.Mdb, order); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildCreateTransactionResponse(order), nil
|
||||||
|
}
|
||||||
|
|
||||||
if !data.IsChainEnabled(network) {
|
if !data.IsChainEnabled(network) {
|
||||||
return nil, constant.ChainNotEnabled
|
return nil, constant.ChainNotEnabled
|
||||||
}
|
}
|
||||||
|
if _, err = ensureEnabledOrderAsset(network, token); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
||||||
|
if rate <= 0 {
|
||||||
|
return nil, constant.RateAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||||
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||||
|
return nil, constant.PayAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -125,7 +197,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
NotifyUrl: notifyURL,
|
NotifyUrl: notifyURL,
|
||||||
RedirectUrl: req.RedirectUrl,
|
RedirectUrl: req.RedirectUrl,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
PaymentType: req.PaymentType,
|
PaymentType: paymentType,
|
||||||
PayProvider: mdb.PaymentProviderOnChain,
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
ApiKeyID: apiKeyID(apiKey),
|
ApiKeyID: apiKeyID(apiKey),
|
||||||
}
|
}
|
||||||
@@ -140,19 +212,7 @@ func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
expirationTime := carbon.Now().AddMinutes(config.GetOrderExpirationTime()).Timestamp()
|
return buildCreateTransactionResponse(order), nil
|
||||||
resp := &response.CreateTransactionResponse{
|
|
||||||
TradeId: order.TradeId,
|
|
||||||
OrderId: order.OrderId,
|
|
||||||
Amount: order.Amount,
|
|
||||||
Currency: order.Currency,
|
|
||||||
ActualAmount: order.ActualAmount,
|
|
||||||
ReceiveAddress: order.ReceiveAddress,
|
|
||||||
Token: order.Token,
|
|
||||||
ExpirationTime: expirationTime,
|
|
||||||
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
|
|
||||||
}
|
|
||||||
return resp, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// OrderProcessing marks an order as paid and releases its sqlite reservation.
|
// OrderProcessing marks an order as paid and releases its sqlite reservation.
|
||||||
@@ -185,15 +245,21 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil {
|
|
||||||
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load order to check parent-child relationship
|
// Load order to check parent-child relationship
|
||||||
order, err := data.GetOrderInfoByTradeId(req.TradeId)
|
order, err := data.GetOrderInfoByTradeId(req.TradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if strings.TrimSpace(req.Network) != "" && strings.TrimSpace(req.ReceiveAddress) != "" && strings.TrimSpace(req.Token) != "" && req.Amount > 0 {
|
||||||
|
if unlockErr := data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); unlockErr != nil {
|
||||||
|
log.Sugar.Warnf("[order] fallback unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, unlockErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
|
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
|
||||||
}
|
}
|
||||||
|
if hasTransactionLock(order) {
|
||||||
|
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
||||||
|
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", order.TradeId, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Parent order paid directly: expire all sub-orders and release their locks
|
// Parent order paid directly: expire all sub-orders and release their locks
|
||||||
if order.ParentTradeId == "" {
|
if order.ParentTradeId == "" {
|
||||||
@@ -211,8 +277,10 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
log.Sugar.Warnf("[order] expire provider order failed, trade_id=%s, provider=%s, err=%v", sub.TradeId, sub.PayProvider, err)
|
log.Sugar.Warnf("[order] expire provider order failed, trade_id=%s, provider=%s, err=%v", sub.TradeId, sub.PayProvider, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil {
|
if hasTransactionLock(&sub) {
|
||||||
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
|
if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil {
|
||||||
|
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -263,8 +331,10 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Release parent's own wallet lock
|
// Release parent's own wallet lock
|
||||||
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil {
|
if hasTransactionLock(parent) {
|
||||||
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil {
|
||||||
|
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release sibling locks after their status transitions commit.
|
// Release sibling locks after their status transitions commit.
|
||||||
@@ -274,8 +344,10 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
log.Sugar.Warnf("[order] expire sibling provider order failed, trade_id=%s, provider=%s, err=%v", sib.TradeId, sib.PayProvider, err)
|
log.Sugar.Warnf("[order] expire sibling provider order failed, trade_id=%s, provider=%s, err=%v", sib.TradeId, sib.PayProvider, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
|
if hasTransactionLock(&sib) {
|
||||||
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
|
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
|
||||||
|
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,9 +499,20 @@ func isOnChainOrder(payProvider string) bool {
|
|||||||
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
|
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
|
||||||
}
|
}
|
||||||
|
|
||||||
const MaxSubOrders = 2
|
func hasTransactionLock(order *mdb.Orders) bool {
|
||||||
|
return order != nil &&
|
||||||
|
isOnChainOrder(order.PayProvider) &&
|
||||||
|
strings.TrimSpace(order.Network) != "" &&
|
||||||
|
strings.TrimSpace(order.ReceiveAddress) != "" &&
|
||||||
|
strings.TrimSpace(order.Token) != "" &&
|
||||||
|
order.ActualAmount > 0
|
||||||
|
}
|
||||||
|
|
||||||
// SwitchNetwork creates or returns an existing sub-order for a different token+network.
|
const MaxSubOrders = 1
|
||||||
|
|
||||||
|
// SwitchNetwork completes a status=4 placeholder in place, or creates/returns
|
||||||
|
// an existing sub-order for a different payment target once the parent is
|
||||||
|
// already concrete.
|
||||||
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
|
func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounterResponse, error) {
|
||||||
gCreateTransactionLock.Lock()
|
gCreateTransactionLock.Lock()
|
||||||
defer gCreateTransactionLock.Unlock()
|
defer gCreateTransactionLock.Unlock()
|
||||||
@@ -448,7 +531,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
if parent.ParentTradeId != "" {
|
if parent.ParentTradeId != "" {
|
||||||
return nil, constant.CannotSwitchSubOrder
|
return nil, constant.CannotSwitchSubOrder
|
||||||
}
|
}
|
||||||
if parent.Status != mdb.StatusWaitPay {
|
if parent.Status != mdb.StatusWaitPay && parent.Status != mdb.StatusWaitSelect {
|
||||||
return nil, constant.OrderNotWaitPay
|
return nil, constant.OrderNotWaitPay
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,6 +539,10 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return switchToOkPay(parent, token)
|
return switchToOkPay(parent, token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if parent.Status == mdb.StatusWaitSelect {
|
||||||
|
return completeWaitSelectOrder(parent, token, network)
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Same token+network as parent → mark selected and return
|
// 2. Same token+network as parent → mark selected and return
|
||||||
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
|
if strings.EqualFold(parent.Token, token) && strings.EqualFold(parent.Network, network) {
|
||||||
_ = data.MarkOrderSelected(parent.TradeId)
|
_ = data.MarkOrderSelected(parent.TradeId)
|
||||||
@@ -476,8 +563,9 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return buildCheckoutResponse(existing), nil
|
return buildCheckoutResponse(existing), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Check sub-order limit
|
// 4. Only one sub-order may ever be created under a parent. Existing
|
||||||
count, err := data.CountActiveSubOrders(parent.TradeId)
|
// active sub-orders are reused above; any other target after that is blocked.
|
||||||
|
count, err := data.CountSubOrders(parent.TradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -485,7 +573,16 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return nil, constant.SubOrderLimitExceeded
|
return nil, constant.SubOrderLimitExceeded
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Calculate amount for the new network
|
// 5. Validate the target chain and asset before any rate lookup so
|
||||||
|
// disabled/unknown tokens return the stable unsupported-asset error.
|
||||||
|
if !data.IsChainEnabled(network) {
|
||||||
|
return nil, constant.ChainNotEnabled
|
||||||
|
}
|
||||||
|
if _, err = ensureEnabledOrderAsset(network, token); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Calculate amount for the new network
|
||||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
||||||
if rate <= 0 {
|
if rate <= 0 {
|
||||||
return nil, constant.RateAmountErr
|
return nil, constant.RateAmountErr
|
||||||
@@ -496,10 +593,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return nil, constant.PayAmountErr
|
return nil, constant.PayAmountErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Find and lock wallet
|
// 7. Find and lock wallet
|
||||||
if !data.IsChainEnabled(network) {
|
|
||||||
return nil, constant.ChainNotEnabled
|
|
||||||
}
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -518,7 +612,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return nil, constant.NotAvailableAmountErr
|
return nil, constant.NotAvailableAmountErr
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Create sub-order
|
// 8. Create sub-order
|
||||||
tx := dao.Mdb.Begin()
|
tx := dao.Mdb.Begin()
|
||||||
subOrder := &mdb.Orders{
|
subOrder := &mdb.Orders{
|
||||||
TradeId: subTradeID,
|
TradeId: subTradeID,
|
||||||
@@ -558,7 +652,63 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
|||||||
return buildCheckoutResponse(subOrder), nil
|
return buildCheckoutResponse(subOrder), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func completeWaitSelectOrder(parent *mdb.Orders, token string, network string) (*response.CheckoutCounterResponse, error) {
|
||||||
|
if !data.IsChainEnabled(network) {
|
||||||
|
return nil, constant.ChainNotEnabled
|
||||||
|
}
|
||||||
|
if _, err := ensureEnabledOrderAsset(network, token); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
||||||
|
if rate <= 0 {
|
||||||
|
return nil, constant.RateAmountErr
|
||||||
|
}
|
||||||
|
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
||||||
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||||
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||||
|
return nil, constant.PayAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(walletAddress) <= 0 {
|
||||||
|
return nil, constant.NotAvailableWalletAddress
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
||||||
|
availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(parent.TradeId, network, token, amount, walletAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if availableAddress == "" {
|
||||||
|
return nil, constant.NotAvailableAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := data.CompleteWaitSelectOrder(parent.TradeId, network, token, availableAddress, availableAmount)
|
||||||
|
if err != nil {
|
||||||
|
_ = data.UnLockTransactionByTradeId(parent.TradeId)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
_ = data.UnLockTransactionByTradeId(parent.TradeId)
|
||||||
|
return nil, constant.OrderStatusConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buildCheckoutResponse(order), nil
|
||||||
|
}
|
||||||
|
|
||||||
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
|
func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse {
|
||||||
|
paymentType := mdb.PaymentTypeGmpay
|
||||||
|
if strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay) {
|
||||||
|
paymentType = mdb.PaymentTypeEpay
|
||||||
|
}
|
||||||
return &response.CheckoutCounterResponse{
|
return &response.CheckoutCounterResponse{
|
||||||
TradeId: order.TradeId,
|
TradeId: order.TradeId,
|
||||||
Amount: order.Amount,
|
Amount: order.Amount,
|
||||||
@@ -567,14 +717,93 @@ func buildCheckoutResponse(order *mdb.Orders) *response.CheckoutCounterResponse
|
|||||||
Currency: order.Currency,
|
Currency: order.Currency,
|
||||||
ReceiveAddress: order.ReceiveAddress,
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
Network: order.Network,
|
Network: order.Network,
|
||||||
|
Status: order.Status,
|
||||||
|
PaymentType: strings.ToLower(paymentType),
|
||||||
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
ExpirationTime: order.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
||||||
RedirectUrl: order.RedirectUrl,
|
RedirectUrl: order.RedirectUrl,
|
||||||
PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId),
|
|
||||||
CreatedAt: order.CreatedAt.TimestampMilli(),
|
CreatedAt: order.CreatedAt.TimestampMilli(),
|
||||||
IsSelected: order.IsSelected,
|
IsSelected: order.IsSelected,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func completeWaitSelectOkPayOrder(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
|
||||||
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(parent.Currency))
|
||||||
|
if rate <= 0 {
|
||||||
|
return nil, constant.RateAmountErr
|
||||||
|
}
|
||||||
|
decimalPayAmount := decimal.NewFromFloat(parent.Amount)
|
||||||
|
decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate))
|
||||||
|
if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 {
|
||||||
|
return nil, constant.PayAmountErr
|
||||||
|
}
|
||||||
|
|
||||||
|
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), data.GetAmountPrecision())
|
||||||
|
returnURL := strings.TrimSpace(parent.RedirectUrl)
|
||||||
|
if returnURL == "" {
|
||||||
|
returnURL = data.GetOkPayReturnURL()
|
||||||
|
}
|
||||||
|
if returnURL == "" {
|
||||||
|
returnURL = fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), parent.TradeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := dao.Mdb.Begin()
|
||||||
|
providerRow := &mdb.ProviderOrder{
|
||||||
|
TradeId: parent.TradeId,
|
||||||
|
Provider: mdb.PaymentProviderOkPay,
|
||||||
|
ProviderOrderID: "",
|
||||||
|
PayURL: "",
|
||||||
|
Amount: amount,
|
||||||
|
Coin: token,
|
||||||
|
Status: mdb.ProviderOrderStatusCreating,
|
||||||
|
NotifyRaw: "",
|
||||||
|
}
|
||||||
|
if err := data.UpsertProviderOrderCreatingWithTransaction(tx, providerRow); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit().Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
okpayOrder, err := createOkPayDepositOrder(parent.TradeId, amount, token, returnURL)
|
||||||
|
if err != nil {
|
||||||
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
return nil, constant.PaymentProviderCreateErr
|
||||||
|
}
|
||||||
|
|
||||||
|
finalizeTx := dao.Mdb.Begin()
|
||||||
|
if err = data.UpdateProviderOrderCreatedWithTransaction(finalizeTx, parent.TradeId, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updated, err := data.CompleteWaitSelectOkPayOrderWithTransaction(finalizeTx, parent.TradeId, token, amount)
|
||||||
|
if err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
return nil, constant.OrderStatusConflict
|
||||||
|
}
|
||||||
|
if err = finalizeTx.Commit().Error; err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := buildCheckoutResponse(order)
|
||||||
|
resp.PaymentUrl = okpayOrder.PayURL
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
|
func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterResponse, error) {
|
||||||
if !data.GetOkPayEnabled() {
|
if !data.GetOkPayEnabled() {
|
||||||
return nil, constant.PaymentProviderNotEnabled
|
return nil, constant.PaymentProviderNotEnabled
|
||||||
@@ -586,6 +815,29 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
|
|||||||
return nil, constant.PaymentProviderNotSupport
|
return nil, constant.PaymentProviderNotSupport
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if parent.Status == mdb.StatusWaitSelect {
|
||||||
|
return completeWaitSelectOkPayOrder(parent, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if parent.PayProvider == mdb.PaymentProviderOkPay && strings.EqualFold(parent.Token, token) {
|
||||||
|
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(parent.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
|
||||||
|
return nil, constant.SystemErr
|
||||||
|
}
|
||||||
|
_ = data.MarkOrderSelected(parent.TradeId)
|
||||||
|
_ = data.RefreshOrderExpiration(parent.TradeId)
|
||||||
|
order, err := data.GetOrderInfoByTradeId(parent.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp := buildCheckoutResponse(order)
|
||||||
|
resp.PaymentUrl = providerRow.PayURL
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
existing, err := data.GetSubOrderByTokenPayProvider(parent.TradeId, token, mdb.PaymentProviderOkPay)
|
existing, err := data.GetSubOrderByTokenPayProvider(parent.TradeId, token, mdb.PaymentProviderOkPay)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -598,16 +850,21 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
|
|||||||
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
|
if providerRow.ID == 0 || strings.TrimSpace(providerRow.PayURL) == "" {
|
||||||
return nil, constant.SystemErr
|
return nil, constant.SystemErr
|
||||||
}
|
}
|
||||||
_ = data.MarkOrderSelected(parent.TradeId)
|
updated, markErr := data.MarkProviderSwitchParentSelectedWithTransaction(dao.Mdb, parent.TradeId)
|
||||||
|
if markErr != nil {
|
||||||
|
return nil, markErr
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
return nil, constant.OrderStatusConflict
|
||||||
|
}
|
||||||
_ = data.MarkOrderSelected(existing.TradeId)
|
_ = data.MarkOrderSelected(existing.TradeId)
|
||||||
_ = data.RefreshOrderExpiration(parent.TradeId)
|
|
||||||
existing.IsSelected = true
|
existing.IsSelected = true
|
||||||
resp := buildCheckoutResponse(existing)
|
resp := buildCheckoutResponse(existing)
|
||||||
resp.PaymentUrl = providerRow.PayURL
|
resp.PaymentUrl = providerRow.PayURL
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
count, err := data.CountActiveSubOrders(parent.TradeId)
|
count, err := data.CountSubOrders(parent.TradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -684,14 +941,33 @@ func switchToOkPay(parent *mdb.Orders, token string) (*response.CheckoutCounterR
|
|||||||
_ = data.ExpireOrderByTradeID(subTradeID)
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
return nil, constant.PaymentProviderCreateErr
|
return nil, constant.PaymentProviderCreateErr
|
||||||
}
|
}
|
||||||
if err = data.UpdateProviderOrderCreated(subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
|
||||||
|
finalizeTx := dao.Mdb.Begin()
|
||||||
|
if err = data.UpdateProviderOrderCreatedWithTransaction(finalizeTx, subTradeID, mdb.PaymentProviderOkPay, okpayOrder.ProviderOrderID, okpayOrder.PayURL); err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
updated, err := data.MarkProviderSwitchParentSelectedWithTransaction(finalizeTx, parent.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !updated {
|
||||||
|
finalizeTx.Rollback()
|
||||||
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
|
return nil, constant.OrderStatusConflict
|
||||||
|
}
|
||||||
|
if err = finalizeTx.Commit().Error; err != nil {
|
||||||
|
finalizeTx.Rollback()
|
||||||
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
_ = data.MarkProviderOrderFailed(subTradeID, mdb.PaymentProviderOkPay)
|
||||||
_ = data.ExpireOrderByTradeID(subTradeID)
|
_ = data.ExpireOrderByTradeID(subTradeID)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = data.MarkOrderSelected(parent.TradeId)
|
|
||||||
_ = data.RefreshOrderExpiration(parent.TradeId)
|
|
||||||
|
|
||||||
resp := buildCheckoutResponse(subOrder)
|
resp := buildCheckoutResponse(subOrder)
|
||||||
resp.PaymentUrl = okpayOrder.PayURL
|
resp.PaymentUrl = okpayOrder.PayURL
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
"github.com/go-resty/resty/v2"
|
"github.com/go-resty/resty/v2"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
|
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
|
||||||
@@ -85,6 +87,85 @@ func TestCreateTransactionRejectsPrivateNotifyURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTransactionCreatesWaitSelectPlaceholderWithoutTokenNetwork(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
req := newCreateTransactionRequest("order_wait_select_1", 12.34)
|
||||||
|
req.Token = ""
|
||||||
|
req.Network = ""
|
||||||
|
resp, err := CreateTransaction(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create placeholder transaction: %v", err)
|
||||||
|
}
|
||||||
|
if resp.Status != mdb.StatusWaitSelect {
|
||||||
|
t.Fatalf("response status = %d, want %d", resp.Status, mdb.StatusWaitSelect)
|
||||||
|
}
|
||||||
|
if resp.Token != "" || resp.ReceiveAddress != "" || resp.ActualAmount != 0 {
|
||||||
|
t.Fatalf("placeholder chain fields = token %q address %q actual %.4f", resp.Token, resp.ReceiveAddress, resp.ActualAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload placeholder order: %v", err)
|
||||||
|
}
|
||||||
|
if order.Status != mdb.StatusWaitSelect {
|
||||||
|
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusWaitSelect)
|
||||||
|
}
|
||||||
|
if order.PaymentType != mdb.PaymentTypeGmpay {
|
||||||
|
t.Fatalf("placeholder payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeGmpay)
|
||||||
|
}
|
||||||
|
if order.Token != "" || order.Network != "" || order.ReceiveAddress != "" || order.ActualAmount != 0 {
|
||||||
|
t.Fatalf("placeholder order has chain fields: %+v", order)
|
||||||
|
}
|
||||||
|
|
||||||
|
var locks int64
|
||||||
|
if err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).Where("trade_id = ?", resp.TradeId).Count(&locks).Error; err != nil {
|
||||||
|
t.Fatalf("count transaction locks: %v", err)
|
||||||
|
}
|
||||||
|
if locks != 0 {
|
||||||
|
t.Fatalf("placeholder lock count = %d, want 0", locks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTransactionRejectsPartialTokenNetwork(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
req := newCreateTransactionRequest("order_partial_token_network_1", 1)
|
||||||
|
req.Token = ""
|
||||||
|
if _, err := CreateTransaction(req, nil); err != constant.ParamsMarshalErr {
|
||||||
|
t.Fatalf("missing token error = %v, want %v", err, constant.ParamsMarshalErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
req = newCreateTransactionRequest("order_partial_token_network_2", 1)
|
||||||
|
req.Network = ""
|
||||||
|
if _, err := CreateTransaction(req, nil); err != constant.ParamsMarshalErr {
|
||||||
|
t.Fatalf("missing network error = %v, want %v", err, constant.ParamsMarshalErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTransactionNormalizesEpayPaymentTypeCase(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
req := newCreateTransactionRequest("order_epay_payment_type_1", 1)
|
||||||
|
req.Token = ""
|
||||||
|
req.Network = ""
|
||||||
|
req.PaymentType = "epay"
|
||||||
|
resp, err := CreateTransaction(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create epay-compatible transaction: %v", err)
|
||||||
|
}
|
||||||
|
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload order: %v", err)
|
||||||
|
}
|
||||||
|
if order.PaymentType != mdb.PaymentTypeEpay {
|
||||||
|
t.Fatalf("payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -281,6 +362,564 @@ func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateTransactionCreatesTonOrderAndRawLock(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.5}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
wallet, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add ton wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := newCreateTransactionRequest("order_ton_native_1", 10)
|
||||||
|
req.Network = mdb.NetworkTon
|
||||||
|
req.Token = "TON"
|
||||||
|
|
||||||
|
resp, err := CreateTransaction(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create TON transaction: %v", err)
|
||||||
|
}
|
||||||
|
if resp.ReceiveAddress != wallet.Address {
|
||||||
|
t.Fatalf("receive address = %q, want %q", resp.ReceiveAddress, wallet.Address)
|
||||||
|
}
|
||||||
|
if resp.Token != "TON" {
|
||||||
|
t.Fatalf("token = %q, want TON", resp.Token)
|
||||||
|
}
|
||||||
|
if got := fmt.Sprintf("%.2f", resp.ActualAmount); got != "5.00" {
|
||||||
|
t.Fatalf("actual amount = %s, want 5.00", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", resp.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup ton runtime lock: %v", err)
|
||||||
|
}
|
||||||
|
if tradeID != resp.TradeId {
|
||||||
|
t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTransactionCreatesTonOrderWithConfiguredPrecision(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupSystem, mdb.SettingKeyAmountPrecision, "4", mdb.SettingTypeInt); err != nil {
|
||||||
|
t.Fatalf("set amount precision: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.12345}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil {
|
||||||
|
t.Fatalf("add ton wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := newCreateTransactionRequest("order_ton_precision_1", 10)
|
||||||
|
req.Network = mdb.NetworkTon
|
||||||
|
req.Token = "TON"
|
||||||
|
resp, err := CreateTransaction(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create TON transaction: %v", err)
|
||||||
|
}
|
||||||
|
if got := fmt.Sprintf("%.4f", resp.ActualAmount); got != "1.2345" {
|
||||||
|
t.Fatalf("actual amount = %s, want 1.2345", got)
|
||||||
|
}
|
||||||
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.Bounce(true).String(), "TON", 1.2345)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup ton runtime lock: %v", err)
|
||||||
|
}
|
||||||
|
if tradeID != resp.TradeId {
|
||||||
|
t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTransactionRejectsUnsupportedTonToken(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
req := newCreateTransactionRequest("order_ton_gram_1", 10)
|
||||||
|
req.Network = mdb.NetworkTon
|
||||||
|
req.Token = "GRAM"
|
||||||
|
|
||||||
|
_, err := CreateTransaction(req, nil)
|
||||||
|
if err != constant.SupportedAssetNotFound {
|
||||||
|
t.Fatalf("create unsupported TON token error = %v, want %v", err, constant.SupportedAssetNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTransactionRejectsDisabledTonTokenBeforeRate(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Model(&mdb.ChainToken{}).
|
||||||
|
Where("network = ? AND symbol = ?", mdb.NetworkTon, "TON").
|
||||||
|
Update("enabled", false).Error; err != nil {
|
||||||
|
t.Fatalf("disable TON token: %v", err)
|
||||||
|
}
|
||||||
|
req := newCreateTransactionRequest("order_ton_disabled_1", 10)
|
||||||
|
req.Network = mdb.NetworkTon
|
||||||
|
req.Token = "TON"
|
||||||
|
|
||||||
|
_, err := CreateTransaction(req, nil)
|
||||||
|
if err != constant.SupportedAssetNotFound {
|
||||||
|
t.Fatalf("create disabled TON token error = %v, want %v", err, constant.SupportedAssetNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTransactionSupportedTonTokenWithoutRateReturnsRateError(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
|
||||||
|
t.Fatalf("clear rate api url: %v", err)
|
||||||
|
}
|
||||||
|
req := newCreateTransactionRequest("order_ton_missing_rate_1", 10)
|
||||||
|
req.Network = mdb.NetworkTon
|
||||||
|
req.Token = "TON"
|
||||||
|
|
||||||
|
_, err := CreateTransaction(req, nil)
|
||||||
|
if err != constant.RateAmountErr {
|
||||||
|
t.Fatalf("create TON without rate error = %v, want %v", err, constant.RateAmountErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchNetworkCreatesTonSubOrderAndRawLock(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1,"ton":0.5}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||||
|
t.Fatalf("add tron wallet: %v", err)
|
||||||
|
}
|
||||||
|
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
wallet, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("add ton wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentReq := newCreateTransactionRequest("order_switch_ton_1", 10)
|
||||||
|
parentReq.Network = mdb.NetworkTron
|
||||||
|
parentResp, err := CreateTransaction(parentReq, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create parent transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "TON",
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("switch to TON: %v", err)
|
||||||
|
}
|
||||||
|
if subResp.Network != mdb.NetworkTon || subResp.Token != "TON" {
|
||||||
|
t.Fatalf("sub order network/token = %s/%s, want ton/TON", subResp.Network, subResp.Token)
|
||||||
|
}
|
||||||
|
if subResp.ReceiveAddress != wallet.Address {
|
||||||
|
t.Fatalf("sub order receive address = %q, want %q", subResp.ReceiveAddress, wallet.Address)
|
||||||
|
}
|
||||||
|
if got := fmt.Sprintf("%.2f", subResp.ActualAmount); got != "5.00" {
|
||||||
|
t.Fatalf("sub order actual amount = %s, want 5.00", got)
|
||||||
|
}
|
||||||
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", subResp.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup TON sub-order lock: %v", err)
|
||||||
|
}
|
||||||
|
if tradeID != subResp.TradeId {
|
||||||
|
t.Fatalf("TON sub-order lock trade_id = %q, want %q", tradeID, subResp.TradeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchNetworkCompletesWaitSelectPlaceholderInPlace(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddress("TWaitSelectAddress001"); err != nil {
|
||||||
|
t.Fatalf("add tron wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := newCreateTransactionRequest("order_wait_select_switch_1", 10)
|
||||||
|
req.Token = ""
|
||||||
|
req.Network = ""
|
||||||
|
parentResp, err := CreateTransaction(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create placeholder: %v", err)
|
||||||
|
}
|
||||||
|
if parentResp.Status != mdb.StatusWaitSelect {
|
||||||
|
t.Fatalf("parent response status = %d, want %d", parentResp.Status, mdb.StatusWaitSelect)
|
||||||
|
}
|
||||||
|
|
||||||
|
switched, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("switch placeholder to tron/usdt: %v", err)
|
||||||
|
}
|
||||||
|
if switched.TradeId != parentResp.TradeId {
|
||||||
|
t.Fatalf("switch returned trade_id = %q, want parent %q", switched.TradeId, parentResp.TradeId)
|
||||||
|
}
|
||||||
|
if switched.Status != mdb.StatusWaitPay {
|
||||||
|
t.Fatalf("switch status = %d, want %d", switched.Status, mdb.StatusWaitPay)
|
||||||
|
}
|
||||||
|
if switched.IsSelected {
|
||||||
|
t.Fatal("switch is_selected = true, want false")
|
||||||
|
}
|
||||||
|
if switched.PaymentUrl != "" {
|
||||||
|
t.Fatalf("switch payment_url = %q, want empty for unselected chain parent", switched.PaymentUrl)
|
||||||
|
}
|
||||||
|
if switched.Token != "USDT" || switched.Network != mdb.NetworkTron || switched.ReceiveAddress != "TWaitSelectAddress001" {
|
||||||
|
t.Fatalf("switch chain fields = token %q network %q address %q", switched.Token, switched.Network, switched.ReceiveAddress)
|
||||||
|
}
|
||||||
|
if got := fmt.Sprintf("%.2f", switched.ActualAmount); got != "1.00" {
|
||||||
|
t.Fatalf("switch actual amount = %s, want 1.00", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload parent: %v", err)
|
||||||
|
}
|
||||||
|
if parent.Status != mdb.StatusWaitPay || parent.ParentTradeId != "" {
|
||||||
|
t.Fatalf("parent status/parent_trade_id = %d/%q", parent.Status, parent.ParentTradeId)
|
||||||
|
}
|
||||||
|
if parent.IsSelected {
|
||||||
|
t.Fatal("parent is_selected = true, want false")
|
||||||
|
}
|
||||||
|
if parent.PaymentType != mdb.PaymentTypeGmpay {
|
||||||
|
t.Fatalf("parent payment_type = %q, want %q", parent.PaymentType, mdb.PaymentTypeGmpay)
|
||||||
|
}
|
||||||
|
count, err := data.CountActiveSubOrders(parent.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count sub orders: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("active sub-order count = %d, want 0", count)
|
||||||
|
}
|
||||||
|
lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, parent.ReceiveAddress, parent.Token, parent.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup parent lock: %v", err)
|
||||||
|
}
|
||||||
|
if lockTradeID != parent.TradeId {
|
||||||
|
t.Fatalf("parent lock trade_id = %q, want %q", lockTradeID, parent.TradeId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchNetworkAfterWaitSelectCompletionUsesExistingSubOrderFlow(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddress("TWaitSelectAddress002"); err != nil {
|
||||||
|
t.Fatalf("add tron wallet: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455668"); err != nil {
|
||||||
|
t.Fatalf("add ethereum wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := newCreateTransactionRequest("order_wait_select_reswitch_1", 10)
|
||||||
|
req.Token = ""
|
||||||
|
req.Network = ""
|
||||||
|
parentResp, err := CreateTransaction(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create placeholder: %v", err)
|
||||||
|
}
|
||||||
|
first, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first switch: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second switch: %v", err)
|
||||||
|
}
|
||||||
|
if second.TradeId == first.TradeId {
|
||||||
|
t.Fatalf("second switch trade_id = parent %q, want sub-order", second.TradeId)
|
||||||
|
}
|
||||||
|
if second.Network != mdb.NetworkEthereum || second.Token != "USDT" {
|
||||||
|
t.Fatalf("second switch token/network = %s/%s", second.Token, second.Network)
|
||||||
|
}
|
||||||
|
count, err := data.CountActiveSubOrders(parentResp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count active sub orders: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Fatalf("active sub-order count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkBsc,
|
||||||
|
})
|
||||||
|
if err != constant.SubOrderLimitExceeded {
|
||||||
|
t.Fatalf("third switch error = %v, want %v", err, constant.SubOrderLimitExceeded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchNetworkRejectsUnsupportedTonToken(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddress("TTestTronAddress002"); err != nil {
|
||||||
|
t.Fatalf("add tron wallet: %v", err)
|
||||||
|
}
|
||||||
|
parentResp, err := CreateTransaction(newCreateTransactionRequest("order_switch_gram_1", 10), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create parent transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "GRAM",
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
})
|
||||||
|
if err != constant.SupportedAssetNotFound {
|
||||||
|
t.Fatalf("switch unsupported TON token error = %v, want %v", err, constant.SupportedAssetNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryProcessTonTransferMarksOrderPaidAndReleasesRawLock(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.5}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil {
|
||||||
|
t.Fatalf("add ton wallet: %v", err)
|
||||||
|
}
|
||||||
|
req := newCreateTransactionRequest("order_ton_process_1", 10)
|
||||||
|
req.Network = mdb.NetworkTon
|
||||||
|
req.Token = "TON"
|
||||||
|
resp, err := CreateTransaction(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create TON transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer := &TonObservedTransfer{
|
||||||
|
ReceiveAddress: resp.ReceiveAddress,
|
||||||
|
Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true},
|
||||||
|
RawAmount: big.NewInt(5_000_000_000),
|
||||||
|
Amount: resp.ActualAmount,
|
||||||
|
BlockTimeMs: time.Now().Add(time.Second).UnixMilli(),
|
||||||
|
LT: 100,
|
||||||
|
TxHashHex: strings.Repeat("1", 64),
|
||||||
|
BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 100, strings.Repeat("1", 64)),
|
||||||
|
}
|
||||||
|
TryProcessTonTransfer(transfer)
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload order: %v", err)
|
||||||
|
}
|
||||||
|
if order.Status != mdb.StatusPaySuccess {
|
||||||
|
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess)
|
||||||
|
}
|
||||||
|
if order.BlockTransactionId != transfer.BlockID {
|
||||||
|
t.Fatalf("block transaction id = %q, want %q", order.BlockTransactionId, transfer.BlockID)
|
||||||
|
}
|
||||||
|
lock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", resp.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup ton lock: %v", err)
|
||||||
|
}
|
||||||
|
if lock != "" {
|
||||||
|
t.Fatalf("TON lock still exists after payment: %s", lock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryProcessTonJettonTransferMarksUSDTOrderPaid(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil {
|
||||||
|
t.Fatalf("add ton wallet: %v", err)
|
||||||
|
}
|
||||||
|
req := newCreateTransactionRequest("order_ton_usdt_process_1", 10)
|
||||||
|
req.Network = mdb.NetworkTon
|
||||||
|
req.Token = "USDT"
|
||||||
|
resp, err := CreateTransaction(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create TON USDT transaction: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
transfer := &TonObservedTransfer{
|
||||||
|
ReceiveAddress: resp.ReceiveAddress,
|
||||||
|
Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", Decimals: 6, Enabled: true},
|
||||||
|
RawAmount: big.NewInt(1_000_000),
|
||||||
|
Amount: resp.ActualAmount,
|
||||||
|
BlockTimeMs: time.Now().Add(time.Second).UnixMilli(),
|
||||||
|
LT: 110,
|
||||||
|
TxHashHex: strings.Repeat("4", 64),
|
||||||
|
BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 110, strings.Repeat("4", 64)),
|
||||||
|
}
|
||||||
|
TryProcessTonTransfer(transfer)
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload order: %v", err)
|
||||||
|
}
|
||||||
|
if order.Status != mdb.StatusPaySuccess {
|
||||||
|
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess)
|
||||||
|
}
|
||||||
|
if order.BlockTransactionId != transfer.BlockID {
|
||||||
|
t.Fatalf("block transaction id = %q, want %q", order.BlockTransactionId, transfer.BlockID)
|
||||||
|
}
|
||||||
|
lock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "USDT", resp.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup TON USDT lock: %v", err)
|
||||||
|
}
|
||||||
|
if lock != "" {
|
||||||
|
t.Fatalf("TON USDT lock still exists after payment: %s", lock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryProcessTonTransferSkipsTransfersBeforeOrderCreation(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"ton":0.5}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil {
|
||||||
|
t.Fatalf("add ton wallet: %v", err)
|
||||||
|
}
|
||||||
|
req := newCreateTransactionRequest("order_ton_old_transfer_1", 10)
|
||||||
|
req.Network = mdb.NetworkTon
|
||||||
|
req.Token = "TON"
|
||||||
|
resp, err := CreateTransaction(req, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create TON transaction: %v", err)
|
||||||
|
}
|
||||||
|
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
TryProcessTonTransfer(&TonObservedTransfer{
|
||||||
|
ReceiveAddress: resp.ReceiveAddress,
|
||||||
|
Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true},
|
||||||
|
RawAmount: big.NewInt(5_000_000_000),
|
||||||
|
Amount: resp.ActualAmount,
|
||||||
|
BlockTimeMs: order.CreatedAt.TimestampMilli() - 1,
|
||||||
|
LT: 101,
|
||||||
|
TxHashHex: strings.Repeat("2", 64),
|
||||||
|
BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 101, strings.Repeat("2", 64)),
|
||||||
|
})
|
||||||
|
|
||||||
|
order, err = data.GetOrderInfoByTradeId(resp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload order: %v", err)
|
||||||
|
}
|
||||||
|
if order.Status != mdb.StatusWaitPay {
|
||||||
|
t.Fatalf("order status = %d, want wait-pay", order.Status)
|
||||||
|
}
|
||||||
|
if order.BlockTransactionId != "" {
|
||||||
|
t.Fatalf("old transfer set block transaction id = %q", order.BlockTransactionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTryProcessTonSubOrderPaysParentAndReleasesLocks(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1,"ton":0.5}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := data.AddWalletAddress("TTestTronAddressForTonSub"); err != nil {
|
||||||
|
t.Fatalf("add tron wallet: %v", err)
|
||||||
|
}
|
||||||
|
addr := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, addr.StringRaw()); err != nil {
|
||||||
|
t.Fatalf("add ton wallet: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentReq := newCreateTransactionRequest("order_ton_sub_parent_1", 10)
|
||||||
|
parentReq.Network = mdb.NetworkTron
|
||||||
|
parentResp, err := CreateTransaction(parentReq, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create parent order: %v", err)
|
||||||
|
}
|
||||||
|
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
|
TradeId: parentResp.TradeId,
|
||||||
|
Token: "TON",
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("switch to TON: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
TryProcessTonTransfer(&TonObservedTransfer{
|
||||||
|
ReceiveAddress: subResp.ReceiveAddress,
|
||||||
|
Token: mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true},
|
||||||
|
RawAmount: big.NewInt(5_000_000_000),
|
||||||
|
Amount: subResp.ActualAmount,
|
||||||
|
BlockTimeMs: time.Now().Add(time.Second).UnixMilli(),
|
||||||
|
LT: 102,
|
||||||
|
TxHashHex: strings.Repeat("3", 64),
|
||||||
|
BlockID: TonCanonicalBlockTransactionID(addr.StringRaw(), 102, strings.Repeat("3", 64)),
|
||||||
|
})
|
||||||
|
|
||||||
|
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload parent order: %v", err)
|
||||||
|
}
|
||||||
|
if parent.Status != mdb.StatusPaySuccess {
|
||||||
|
t.Fatalf("parent status = %d, want paid", parent.Status)
|
||||||
|
}
|
||||||
|
sub, err := data.GetOrderInfoByTradeId(subResp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload sub order: %v", err)
|
||||||
|
}
|
||||||
|
if sub.Status != mdb.StatusPaySuccess {
|
||||||
|
t.Fatalf("sub status = %d, want paid", sub.Status)
|
||||||
|
}
|
||||||
|
parentLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, parentResp.ReceiveAddress, parentResp.Token, parentResp.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup parent lock: %v", err)
|
||||||
|
}
|
||||||
|
if parentLock != "" {
|
||||||
|
t.Fatalf("parent lock still exists: %s", parentLock)
|
||||||
|
}
|
||||||
|
subLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, addr.StringRaw(), "TON", subResp.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup sub lock: %v", err)
|
||||||
|
}
|
||||||
|
if subLock != "" {
|
||||||
|
t.Fatalf("TON sub-order lock still exists: %s", subLock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -799,7 +1438,6 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
|||||||
t.Fatalf("create parent order: %v", err)
|
t.Fatalf("create parent order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create two sub-orders on different networks.
|
|
||||||
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||||
TradeId: parentResp.TradeId,
|
TradeId: parentResp.TradeId,
|
||||||
Token: "usdt",
|
Token: "usdt",
|
||||||
@@ -809,13 +1447,29 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
|||||||
t.Fatalf("switch to ethereum sub-order: %v", err)
|
t.Fatalf("switch to ethereum sub-order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
bscTradeID := GenerateCode()
|
||||||
TradeId: parentResp.TradeId,
|
bscAddress := strings.ToLower("0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
|
||||||
Token: "usdt",
|
subBsc := &mdb.Orders{
|
||||||
Network: mdb.NetworkBsc,
|
TradeId: bscTradeID,
|
||||||
})
|
OrderId: bscTradeID,
|
||||||
if err != nil {
|
ParentTradeId: parentResp.TradeId,
|
||||||
t.Fatalf("switch to bsc sub-order: %v", err)
|
Amount: parentResp.Amount,
|
||||||
|
Currency: parentResp.Currency,
|
||||||
|
ActualAmount: subEthResp.ActualAmount,
|
||||||
|
ReceiveAddress: bscAddress,
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkBsc,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
IsSelected: true,
|
||||||
|
CallBackConfirm: mdb.CallBackConfirmOk,
|
||||||
|
PaymentType: mdb.PaymentTypeGmpay,
|
||||||
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(subBsc).Error; err != nil {
|
||||||
|
t.Fatalf("seed bsc sibling sub-order: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.LockTransaction(mdb.NetworkBsc, bscAddress, "USDT", bscTradeID, subBsc.ActualAmount, time.Hour); err != nil {
|
||||||
|
t.Fatalf("lock bsc sibling amount: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pay the Ethereum sub-order.
|
// Pay the Ethereum sub-order.
|
||||||
@@ -832,12 +1486,12 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// BSC sibling must be expired.
|
// BSC sibling must be expired.
|
||||||
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId)
|
reloadedBsc, err := data.GetOrderInfoByTradeId(subBsc.TradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("reload bsc sub-order: %v", err)
|
t.Fatalf("reload bsc sub-order: %v", err)
|
||||||
}
|
}
|
||||||
if subBsc.Status != mdb.StatusExpired {
|
if reloadedBsc.Status != mdb.StatusExpired {
|
||||||
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired)
|
t.Fatalf("bsc sibling status = %d, want %d (expired)", reloadedBsc.Status, mdb.StatusExpired)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parent runtime lock must be released.
|
// Parent runtime lock must be released.
|
||||||
@@ -852,7 +1506,7 @@ func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
|||||||
|
|
||||||
// BSC sibling runtime lock must be released.
|
// BSC sibling runtime lock must be released.
|
||||||
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||||
mdb.NetworkBsc, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount)
|
mdb.NetworkBsc, subBsc.ReceiveAddress, subBsc.Token, subBsc.ActualAmount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("check bsc sibling runtime lock: %v", err)
|
t.Fatalf("check bsc sibling runtime lock: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"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"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
)
|
)
|
||||||
@@ -20,6 +23,10 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
|
|||||||
if orderInfo.ID <= 0 {
|
if orderInfo.ID <= 0 {
|
||||||
return nil, ErrOrder
|
return nil, ErrOrder
|
||||||
}
|
}
|
||||||
|
paymentType := mdb.PaymentTypeGmpay
|
||||||
|
if strings.EqualFold(orderInfo.PaymentType, mdb.PaymentTypeEpay) {
|
||||||
|
paymentType = mdb.PaymentTypeEpay
|
||||||
|
}
|
||||||
|
|
||||||
resp := &response.CheckoutCounterResponse{
|
resp := &response.CheckoutCounterResponse{
|
||||||
TradeId: orderInfo.TradeId,
|
TradeId: orderInfo.TradeId,
|
||||||
@@ -29,10 +36,21 @@ func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterRespo
|
|||||||
Currency: orderInfo.Currency,
|
Currency: orderInfo.Currency,
|
||||||
ReceiveAddress: orderInfo.ReceiveAddress,
|
ReceiveAddress: orderInfo.ReceiveAddress,
|
||||||
Network: orderInfo.Network,
|
Network: orderInfo.Network,
|
||||||
|
Status: orderInfo.Status,
|
||||||
|
PaymentType: strings.ToLower(paymentType),
|
||||||
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(),
|
||||||
RedirectUrl: orderInfo.RedirectUrl,
|
RedirectUrl: orderInfo.RedirectUrl,
|
||||||
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
|
CreatedAt: orderInfo.CreatedAt.TimestampMilli(),
|
||||||
IsSelected: orderInfo.IsSelected,
|
IsSelected: orderInfo.IsSelected,
|
||||||
}
|
}
|
||||||
|
if orderInfo.PayProvider == mdb.PaymentProviderOkPay {
|
||||||
|
providerRow, rowErr := data.GetProviderOrderByTradeIDAndProvider(orderInfo.TradeId, mdb.PaymentProviderOkPay)
|
||||||
|
if rowErr != nil {
|
||||||
|
return nil, rowErr
|
||||||
|
}
|
||||||
|
if providerRow.ID > 0 {
|
||||||
|
resp.PaymentUrl = providerRow.PayURL
|
||||||
|
}
|
||||||
|
}
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,24 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/notify"
|
"github.com/GMWalletApp/epusdt/notify"
|
||||||
"github.com/ethereum/go-ethereum/common"
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func upsertTaskServiceTestChainToken(t *testing.T, token mdb.ChainToken) {
|
||||||
|
t.Helper()
|
||||||
|
if err := dao.Mdb.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "network"}, {Name: "symbol"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{
|
||||||
|
"contract_address",
|
||||||
|
"decimals",
|
||||||
|
"enabled",
|
||||||
|
"min_amount",
|
||||||
|
}),
|
||||||
|
}).Create(&token).Error; err != nil {
|
||||||
|
t.Fatalf("seed chain token: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSendPaymentNotificationUsesLatestOrderUpdatedAt(t *testing.T) {
|
func TestSendPaymentNotificationUsesLatestOrderUpdatedAt(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -158,15 +174,13 @@ func TestTryProcessEvmERC20TransferSkipsTransfersBeforeOrderCreation(t *testing.
|
|||||||
contract := common.HexToAddress("0x3333333333333333333333333333333333333333")
|
contract := common.HexToAddress("0x3333333333333333333333333333333333333333")
|
||||||
receiveAddress := common.HexToAddress("0x4444444444444444444444444444444444444444")
|
receiveAddress := common.HexToAddress("0x4444444444444444444444444444444444444444")
|
||||||
|
|
||||||
if err := dao.Mdb.Create(&mdb.ChainToken{
|
upsertTaskServiceTestChainToken(t, mdb.ChainToken{
|
||||||
Network: mdb.NetworkEthereum,
|
Network: mdb.NetworkEthereum,
|
||||||
Symbol: tokenSym,
|
Symbol: tokenSym,
|
||||||
ContractAddress: contract.Hex(),
|
ContractAddress: contract.Hex(),
|
||||||
Decimals: 6,
|
Decimals: 6,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
}).Error; err != nil {
|
})
|
||||||
t.Fatalf("seed chain token: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
order := &mdb.Orders{
|
order := &mdb.Orders{
|
||||||
TradeId: tradeID,
|
TradeId: tradeID,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/xssnick/tonutils-go/liteclient"
|
||||||
|
"github.com/xssnick/tonutils-go/ton"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectTonLiteAPI builds a tonutils-go API client from a liteserver global
|
||||||
|
// config URL. Scanner and manual verification use this same connection setup.
|
||||||
|
func ConnectTonLiteAPI(ctx context.Context, configURL string, requestTimeout time.Duration, reconnectLimit int) (ton.APIClientWrapped, func(), error) {
|
||||||
|
configURL = strings.TrimSpace(configURL)
|
||||||
|
if configURL == "" {
|
||||||
|
return nil, func() {}, fmt.Errorf("ton lite config url is empty")
|
||||||
|
}
|
||||||
|
if reconnectLimit <= 0 {
|
||||||
|
reconnectLimit = 3
|
||||||
|
}
|
||||||
|
cfg, err := liteclient.GetConfigFromUrl(ctx, configURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, func() {}, err
|
||||||
|
}
|
||||||
|
pool := liteclient.NewConnectionPool()
|
||||||
|
pool.SetOnDisconnect(pool.DefaultReconnect(3*time.Second, reconnectLimit))
|
||||||
|
if err = pool.AddConnectionsFromConfig(ctx, cfg); err != nil {
|
||||||
|
pool.Stop()
|
||||||
|
return nil, func() {}, err
|
||||||
|
}
|
||||||
|
base := ton.NewAPIClient(pool, ton.ProofCheckPolicyFast)
|
||||||
|
base.SetTrustedBlockFromConfig(cfg)
|
||||||
|
api := base.WithRetry(2).WithTimeout(requestTimeout).WithLSInfoInErrors()
|
||||||
|
return api, pool.Stop, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/math"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
|
"github.com/xssnick/tonutils-go/tlb"
|
||||||
|
"github.com/xssnick/tonutils-go/ton/jetton"
|
||||||
|
"github.com/xssnick/tonutils-go/tvm/cell"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
TonNativeSymbol = "TON"
|
||||||
|
TonTransferNotificationOpcode = 0x7362d09c
|
||||||
|
)
|
||||||
|
|
||||||
|
type TonObservedTransfer struct {
|
||||||
|
ReceiveAddress string
|
||||||
|
Token mdb.ChainToken
|
||||||
|
RawAmount *big.Int
|
||||||
|
Amount float64
|
||||||
|
BlockTimeMs int64
|
||||||
|
LT uint64
|
||||||
|
TxHashHex string
|
||||||
|
BlockID string
|
||||||
|
SenderAddress string
|
||||||
|
JettonWallet string
|
||||||
|
}
|
||||||
|
|
||||||
|
func TonCanonicalBlockTransactionID(receiveRaw string, lt uint64, txHashHex string) string {
|
||||||
|
return fmt.Sprintf("ton:%s:%d:%s", strings.ToLower(strings.TrimSpace(receiveRaw)), lt, strings.ToLower(strings.TrimSpace(txHashHex)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseTonInboundTransfer(tx *tlb.Transaction, receive *address.Address, nativeToken *mdb.ChainToken, jettonWalletTokens map[string]mdb.ChainToken) (*TonObservedTransfer, error) {
|
||||||
|
if tx == nil || receive == nil || tx.IO.In == nil || tx.IO.In.MsgType != tlb.MsgTypeInternal {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if isTonTransactionBounced(tx) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
in := tx.IO.In.AsInternal()
|
||||||
|
if in == nil || in.DstAddr == nil || !in.DstAddr.Equals(receive) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
receiveRaw := addressutil.TonRawAddressObjectKey(receive)
|
||||||
|
txHashHex := hex.EncodeToString(tx.Hash)
|
||||||
|
blockID := TonCanonicalBlockTransactionID(receiveRaw, tx.LT, txHashHex)
|
||||||
|
blockTimeMs := int64(tx.Now) * 1000
|
||||||
|
if in.SrcAddr != nil {
|
||||||
|
srcRaw := addressutil.TonRawAddressObjectKey(in.SrcAddr)
|
||||||
|
if token, ok := jettonWalletTokens[srcRaw]; ok {
|
||||||
|
transfer, err := parseTonJettonTransferNotification(in)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
amount := tonRawAmountToFloat(transfer.Amount.Nano(), token.Decimals)
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if token.MinAmount > 0 && amount < token.MinAmount {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &TonObservedTransfer{
|
||||||
|
ReceiveAddress: addressutil.NormalizeTonAddressObject(receive),
|
||||||
|
Token: token,
|
||||||
|
RawAmount: transfer.Amount.Nano(),
|
||||||
|
Amount: amount,
|
||||||
|
BlockTimeMs: blockTimeMs,
|
||||||
|
LT: tx.LT,
|
||||||
|
TxHashHex: txHashHex,
|
||||||
|
BlockID: blockID,
|
||||||
|
SenderAddress: addressutil.NormalizeTonAddressObject(transfer.Sender),
|
||||||
|
JettonWallet: addressutil.NormalizeTonAddressObject(in.SrcAddr),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if tonPayloadIsTransferNotification(in.Body) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if nativeToken == nil || nativeToken.ID == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rawNano := in.Amount.Nano()
|
||||||
|
if rawNano == nil || rawNano.Sign() <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
amount := tonRawAmountToFloat(rawNano, nativeToken.Decimals)
|
||||||
|
if amount <= 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if nativeToken.MinAmount > 0 && amount < nativeToken.MinAmount {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return &TonObservedTransfer{
|
||||||
|
ReceiveAddress: addressutil.NormalizeTonAddressObject(receive),
|
||||||
|
Token: *nativeToken,
|
||||||
|
RawAmount: rawNano,
|
||||||
|
Amount: amount,
|
||||||
|
BlockTimeMs: blockTimeMs,
|
||||||
|
LT: tx.LT,
|
||||||
|
TxHashHex: txHashHex,
|
||||||
|
BlockID: blockID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TryProcessTonTransfer(transfer *TonObservedTransfer) {
|
||||||
|
if transfer == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tokenSym := strings.ToUpper(strings.TrimSpace(transfer.Token.Symbol))
|
||||||
|
defer func() {
|
||||||
|
if err := recover(); err != nil {
|
||||||
|
log.Sugar.Errorf("[TON-%s][%s] TryProcessTonTransfer panic: %v", tokenSym, transfer.ReceiveAddress, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
receive := strings.TrimSpace(transfer.ReceiveAddress)
|
||||||
|
if tokenSym == "" || receive == "" || transfer.Amount <= 0 || transfer.BlockID == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Infof("[TON-%s][%s] observed transfer tx=%s lt=%d amount=%.6f", tokenSym, receive, transfer.BlockID, transfer.LT, transfer.Amount)
|
||||||
|
|
||||||
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, receive, tokenSym, transfer.Amount)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] lock lookup: %v", tokenSym, receive, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tradeID == "" {
|
||||||
|
log.Sugar.Infof("[TON-%s][%s] skip unmatched transfer tx=%s amount=%.6f", tokenSym, receive, transfer.BlockID, transfer.Amount)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] load order: %v", tokenSym, receive, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTon {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s network=%q", tokenSym, receive, tradeID, order.Network)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s token mismatch order=%s", tokenSym, receive, tradeID, order.Token)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tonOrderAddressMatches(order.ReceiveAddress, receive) {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] skip trade_id=%s receive address mismatch order=%s", tokenSym, receive, tradeID, order.ReceiveAddress)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if transfer.BlockTimeMs > 0 && transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() {
|
||||||
|
log.Sugar.Warnf("[TON-%s][%s] skip tx %s because block time %d is before order create time %d", tokenSym, receive, transfer.BlockID, transfer.BlockTimeMs, order.CreatedAt.TimestampMilli())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &request.OrderProcessingRequest{
|
||||||
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
|
Token: tokenSym,
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
TradeId: tradeID,
|
||||||
|
Amount: transfer.Amount,
|
||||||
|
BlockTransactionId: transfer.BlockID,
|
||||||
|
}
|
||||||
|
err = OrderProcessing(req)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
|
||||||
|
log.Sugar.Infof("[TON-%s][%s] skip resolved transfer trade_id=%s tx=%s err=%v", tokenSym, receive, tradeID, transfer.BlockID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Errorf("[TON-%s][%s] OrderProcessing trade_id=%s tx=%s: %v", tokenSym, receive, tradeID, transfer.BlockID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sendPaymentNotification(order)
|
||||||
|
log.Sugar.Infof("[TON-%s][%s] payment processed trade_id=%s tx=%s", tokenSym, receive, tradeID, transfer.BlockID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonOrderAddressMatches(a, b string) bool {
|
||||||
|
ar, errA := addressutil.TonRawAddressKey(a)
|
||||||
|
br, errB := addressutil.TonRawAddressKey(b)
|
||||||
|
return errA == nil && errB == nil && ar == br
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonRawAmountToFloat(rawAmount *big.Int, decimals int) float64 {
|
||||||
|
if rawAmount == nil || rawAmount.Sign() <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if decimals < 0 {
|
||||||
|
decimals = 0
|
||||||
|
}
|
||||||
|
amount := decimal.NewFromBigInt(rawAmount, -int32(decimals))
|
||||||
|
return math.MustParsePrecFloat64(amount.InexactFloat64(), data.MaxAmountPrecision)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTonJettonTransferNotification(in *tlb.InternalMessage) (*jetton.TransferNotification, error) {
|
||||||
|
if in == nil || in.Body == nil {
|
||||||
|
return nil, fmt.Errorf("missing jetton transfer notification body")
|
||||||
|
}
|
||||||
|
var transfer jetton.TransferNotification
|
||||||
|
if err := tlb.LoadFromCell(&transfer, in.Body.BeginParse()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &transfer, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonPayloadIsTransferNotification(body *cell.Cell) bool {
|
||||||
|
if body == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
op, err := body.BeginParse().LoadUInt(32)
|
||||||
|
return err == nil && op == TonTransferNotificationOpcode
|
||||||
|
}
|
||||||
|
|
||||||
|
func isTonTransactionBounced(tx *tlb.Transaction) bool {
|
||||||
|
if tx == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if dsc, ok := tx.Description.(tlb.TransactionDescriptionOrdinary); ok && dsc.BouncePhase != nil {
|
||||||
|
if _, ok = dsc.BouncePhase.Phase.(tlb.BouncePhaseOk); ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureTonTransferMatchesOrder(order *mdb.Orders, transfer *TonObservedTransfer) error {
|
||||||
|
if order == nil || order.ID == 0 {
|
||||||
|
return fmt.Errorf("order not found")
|
||||||
|
}
|
||||||
|
if transfer == nil {
|
||||||
|
return fmt.Errorf("matching ton transfer to order address not found")
|
||||||
|
}
|
||||||
|
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkTon {
|
||||||
|
return fmt.Errorf("order network mismatch")
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(order.Token, transfer.Token.Symbol) {
|
||||||
|
return fmt.Errorf("transaction token mismatch")
|
||||||
|
}
|
||||||
|
if !tonOrderAddressMatches(order.ReceiveAddress, transfer.ReceiveAddress) {
|
||||||
|
return fmt.Errorf("transaction recipient mismatch")
|
||||||
|
}
|
||||||
|
if transfer.BlockTimeMs <= 0 || transfer.BlockTimeMs < order.CreatedAt.TimestampMilli() {
|
||||||
|
return fmt.Errorf("transaction predates the order")
|
||||||
|
}
|
||||||
|
if !amountMatchesRaw(order.ActualAmount, transfer.RawAmount, transfer.Token.Decimals) {
|
||||||
|
return fmt.Errorf("transaction amount mismatch")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
|
"github.com/xssnick/tonutils-go/tlb"
|
||||||
|
"github.com/xssnick/tonutils-go/ton/jetton"
|
||||||
|
"github.com/xssnick/tonutils-go/tvm/cell"
|
||||||
|
)
|
||||||
|
|
||||||
|
func tonTestInboundTx(t *testing.T, src, dst *address.Address, amount tlb.Coins, body *cell.Cell) *tlb.Transaction {
|
||||||
|
t.Helper()
|
||||||
|
return &tlb.Transaction{
|
||||||
|
LT: 123,
|
||||||
|
Now: uint32(time.Now().Unix()),
|
||||||
|
Hash: bytes.Repeat([]byte{0x11}, 32),
|
||||||
|
IO: struct {
|
||||||
|
In *tlb.Message `tlb:"maybe ^"`
|
||||||
|
Out *tlb.MessagesList `tlb:"maybe ^"`
|
||||||
|
}{
|
||||||
|
In: &tlb.Message{
|
||||||
|
MsgType: tlb.MsgTypeInternal,
|
||||||
|
Msg: &tlb.InternalMessage{
|
||||||
|
SrcAddr: src,
|
||||||
|
DstAddr: dst,
|
||||||
|
Amount: amount,
|
||||||
|
Body: body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTonInboundTransferNativeTON(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x22}, 32)).Bounce(false).Testnet(false)
|
||||||
|
native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}
|
||||||
|
|
||||||
|
tx := tonTestInboundTx(t, sender, receive, tlb.MustFromTON("1.23"), cell.BeginCell().EndCell())
|
||||||
|
transfer, err := ParseTonInboundTransfer(tx, receive, native, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
||||||
|
}
|
||||||
|
if transfer == nil {
|
||||||
|
t.Fatal("expected native TON transfer")
|
||||||
|
}
|
||||||
|
if transfer.Token.Symbol != "TON" {
|
||||||
|
t.Fatalf("token = %q, want TON", transfer.Token.Symbol)
|
||||||
|
}
|
||||||
|
if transfer.Amount != 1.23 {
|
||||||
|
t.Fatalf("amount = %v, want 1.23", transfer.Amount)
|
||||||
|
}
|
||||||
|
if transfer.ReceiveAddress != addressutil.NormalizeTonAddressObject(receive) {
|
||||||
|
t.Fatalf("receive address = %q", transfer.ReceiveAddress)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(transfer.BlockID, "ton:"+addressutil.TonRawAddressObjectKey(receive)+":123:") {
|
||||||
|
t.Fatalf("block id = %q, want canonical TON prefix", transfer.BlockID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTonInboundTransferJettonNotification(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false)
|
||||||
|
jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x44}, 32)).Bounce(false).Testnet(false)
|
||||||
|
native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}
|
||||||
|
usdt := mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", Decimals: 6, Enabled: true}
|
||||||
|
|
||||||
|
body, err := tlb.ToCell(jetton.TransferNotification{
|
||||||
|
QueryID: 7,
|
||||||
|
Amount: tlb.MustFromNano(big.NewInt(1230000), 6),
|
||||||
|
Sender: sender,
|
||||||
|
ForwardPayload: cell.BeginCell().EndCell(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build jetton notification body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body)
|
||||||
|
transfer, err := ParseTonInboundTransfer(tx, receive, native, map[string]mdb.ChainToken{
|
||||||
|
addressutil.TonRawAddressObjectKey(jettonWallet): usdt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
||||||
|
}
|
||||||
|
if transfer == nil {
|
||||||
|
t.Fatal("expected USDT jetton transfer")
|
||||||
|
}
|
||||||
|
if transfer.Token.Symbol != "USDT" {
|
||||||
|
t.Fatalf("token = %q, want USDT", transfer.Token.Symbol)
|
||||||
|
}
|
||||||
|
if transfer.Amount != 1.23 {
|
||||||
|
t.Fatalf("amount = %v, want 1.23", transfer.Amount)
|
||||||
|
}
|
||||||
|
if transfer.SenderAddress != addressutil.NormalizeTonAddressObject(sender) {
|
||||||
|
t.Fatalf("sender address = %q", transfer.SenderAddress)
|
||||||
|
}
|
||||||
|
if transfer.JettonWallet != addressutil.NormalizeTonAddressObject(jettonWallet) {
|
||||||
|
t.Fatalf("jetton wallet = %q", transfer.JettonWallet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTonInboundTransferJettonNotificationWithNoGasComputeSkip(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false)
|
||||||
|
jettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x44}, 32)).Bounce(false).Testnet(false)
|
||||||
|
usdt := mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", Decimals: 6, Enabled: true}
|
||||||
|
|
||||||
|
body, err := tlb.ToCell(jetton.TransferNotification{
|
||||||
|
QueryID: 7,
|
||||||
|
Amount: tlb.MustFromNano(big.NewInt(150000), 6),
|
||||||
|
Sender: sender,
|
||||||
|
ForwardPayload: cell.BeginCell().EndCell(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build jetton notification body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := tonTestInboundTx(t, jettonWallet, receive, tlb.FromNanoTONU(1), body)
|
||||||
|
tx.Description = tlb.TransactionDescriptionOrdinary{
|
||||||
|
ComputePhase: tlb.ComputePhase{Phase: tlb.ComputePhaseSkipped{
|
||||||
|
Reason: tlb.ComputeSkipReason{Type: tlb.ComputeSkipReasonNoGas},
|
||||||
|
}},
|
||||||
|
Aborted: true,
|
||||||
|
}
|
||||||
|
transfer, err := ParseTonInboundTransfer(tx, receive, nil, map[string]mdb.ChainToken{
|
||||||
|
addressutil.TonRawAddressObjectKey(jettonWallet): usdt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
||||||
|
}
|
||||||
|
if transfer == nil {
|
||||||
|
t.Fatal("expected no-gas jetton notification to be accepted")
|
||||||
|
}
|
||||||
|
if transfer.Token.Symbol != "USDT" {
|
||||||
|
t.Fatalf("token = %q, want USDT", transfer.Token.Symbol)
|
||||||
|
}
|
||||||
|
if transfer.Amount != 0.15 {
|
||||||
|
t.Fatalf("amount = %v, want 0.15", transfer.Amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTonInboundTransferDoesNotCountUntrustedJettonNotificationAsTON(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x33}, 32)).Bounce(false).Testnet(false)
|
||||||
|
untrustedJettonWallet := address.NewAddress(0, 0, bytes.Repeat([]byte{0x55}, 32)).Bounce(false).Testnet(false)
|
||||||
|
native := &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "TON", Decimals: 9, Enabled: true}
|
||||||
|
|
||||||
|
body, err := tlb.ToCell(jetton.TransferNotification{
|
||||||
|
QueryID: 7,
|
||||||
|
Amount: tlb.MustFromNano(big.NewInt(1230000), 6),
|
||||||
|
Sender: sender,
|
||||||
|
ForwardPayload: cell.BeginCell().EndCell(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build jetton notification body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx := tonTestInboundTx(t, untrustedJettonWallet, receive, tlb.FromNanoTONU(1), body)
|
||||||
|
transfer, err := ParseTonInboundTransfer(tx, receive, native, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTonInboundTransfer(): %v", err)
|
||||||
|
}
|
||||||
|
if transfer != nil {
|
||||||
|
t.Fatalf("untrusted jetton notification produced transfer: %#v", transfer)
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-4
@@ -48,10 +48,12 @@ const sqliteBusyRetryAttempts = 3
|
|||||||
type expirableOrder struct {
|
type expirableOrder struct {
|
||||||
ID uint64 `gorm:"column:id"`
|
ID uint64 `gorm:"column:id"`
|
||||||
TradeId string `gorm:"column:trade_id"`
|
TradeId string `gorm:"column:trade_id"`
|
||||||
|
Status int `gorm:"column:status"`
|
||||||
Network string `gorm:"column:network"`
|
Network string `gorm:"column:network"`
|
||||||
ReceiveAddress string `gorm:"column:receive_address"`
|
ReceiveAddress string `gorm:"column:receive_address"`
|
||||||
Token string `gorm:"column:token"`
|
Token string `gorm:"column:token"`
|
||||||
ActualAmount float64 `gorm:"column:actual_amount"`
|
ActualAmount float64 `gorm:"column:actual_amount"`
|
||||||
|
PayProvider string `gorm:"column:pay_provider"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func runOrderExpirationLoop() {
|
func runOrderExpirationLoop() {
|
||||||
@@ -91,8 +93,8 @@ func processExpiredOrders() {
|
|||||||
var orders []expirableOrder
|
var orders []expirableOrder
|
||||||
err := withSQLiteBusyRetry(func() error {
|
err := withSQLiteBusyRetry(func() error {
|
||||||
return dao.Mdb.Model(&mdb.Orders{}).
|
return dao.Mdb.Model(&mdb.Orders{}).
|
||||||
Select("id", "trade_id", "network", "receive_address", "token", "actual_amount").
|
Select("id", "trade_id", "status", "network", "receive_address", "token", "actual_amount", "pay_provider").
|
||||||
Where("status = ?", mdb.StatusWaitPay).
|
Where("status IN ?", []int{mdb.StatusWaitPay, mdb.StatusWaitSelect}).
|
||||||
Where("created_at <= ?", expirationCutoff).
|
Where("created_at <= ?", expirationCutoff).
|
||||||
Order("id asc").
|
Order("id asc").
|
||||||
Limit(batchSize).
|
Limit(batchSize).
|
||||||
@@ -115,6 +117,17 @@ func processExpiredOrders() {
|
|||||||
if !expired {
|
if !expired {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// StatusWaitSelect placeholders have not reserved a wallet or
|
||||||
|
// amount yet, so there is no transaction lock to release.
|
||||||
|
if order.Status == mdb.StatusWaitSelect {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(order.PayProvider) != "" && order.PayProvider != mdb.PaymentProviderOnChain {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(order.Network) == "" || strings.TrimSpace(order.ReceiveAddress) == "" || strings.TrimSpace(order.Token) == "" || order.ActualAmount <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
|
||||||
log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err)
|
log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err)
|
||||||
}
|
}
|
||||||
@@ -192,8 +205,8 @@ func sendOrderCallback(order *mdb.Orders) error {
|
|||||||
return errors.New("no api key row available for callback")
|
return errors.New("no api key row available for callback")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch order.PaymentType {
|
switch {
|
||||||
case mdb.PaymentTypeEpay:
|
case strings.EqualFold(order.PaymentType, mdb.PaymentTypeEpay):
|
||||||
// EPAY uses pid (integer) and secret_key as "key".
|
// EPAY uses pid (integer) and secret_key as "key".
|
||||||
pidInt, convErr := strconv.Atoi(apiKeyRow.Pid)
|
pidInt, convErr := strconv.Atoi(apiKeyRow.Pid)
|
||||||
if convErr != nil {
|
if convErr != nil {
|
||||||
|
|||||||
+26
-3
@@ -62,6 +62,21 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
t.Fatalf("lock recent order: %v", err)
|
t.Fatalf("lock recent order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
placeholder := &mdb.Orders{
|
||||||
|
TradeId: "trade_wait_select_expired",
|
||||||
|
OrderId: "order_wait_select_expired",
|
||||||
|
Amount: 1,
|
||||||
|
Currency: "CNY",
|
||||||
|
Status: mdb.StatusWaitSelect,
|
||||||
|
NotifyUrl: "https://merchant.example/callback",
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(placeholder).Error; err != nil {
|
||||||
|
t.Fatalf("create placeholder order: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Model(placeholder).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil {
|
||||||
|
t.Fatalf("age placeholder order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
processExpiredOrders()
|
processExpiredOrders()
|
||||||
|
|
||||||
expired, err := data.GetOrderInfoByTradeId(order.TradeId)
|
expired, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||||
@@ -93,6 +108,14 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
if lockTradeID != recentOrder.TradeId {
|
if lockTradeID != recentOrder.TradeId {
|
||||||
t.Fatalf("recent order lock = %s, want %s", lockTradeID, recentOrder.TradeId)
|
t.Fatalf("recent order lock = %s, want %s", lockTradeID, recentOrder.TradeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
expiredPlaceholder, err := data.GetOrderInfoByTradeId(placeholder.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload expired placeholder: %v", err)
|
||||||
|
}
|
||||||
|
if expiredPlaceholder.Status != mdb.StatusExpired {
|
||||||
|
t.Fatalf("placeholder status = %d, want %d", expiredPlaceholder.Status, mdb.StatusExpired)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessExpiredOrdersKeepsPaidOrdersPaid(t *testing.T) {
|
func TestProcessExpiredOrdersKeepsPaidOrdersPaid(t *testing.T) {
|
||||||
@@ -295,7 +318,7 @@ func TestDispatchPendingCallbacksEpayRequiresAck(t *testing.T) {
|
|||||||
BlockTransactionId: "block_callback_epay_fail",
|
BlockTransactionId: "block_callback_epay_fail",
|
||||||
CallbackNum: 0,
|
CallbackNum: 0,
|
||||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||||
PaymentType: mdb.PaymentTypeEpay,
|
PaymentType: "epay",
|
||||||
ApiKeyID: epayKey.ID,
|
ApiKeyID: epayKey.ID,
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
@@ -349,7 +372,7 @@ func TestDispatchPendingCallbacksEpayAcceptsTrimmedOk(t *testing.T) {
|
|||||||
BlockTransactionId: "block_callback_epay_ok",
|
BlockTransactionId: "block_callback_epay_ok",
|
||||||
CallbackNum: 0,
|
CallbackNum: 0,
|
||||||
CallBackConfirm: mdb.CallBackConfirmNo,
|
CallBackConfirm: mdb.CallBackConfirmNo,
|
||||||
PaymentType: mdb.PaymentTypeEpay,
|
PaymentType: "epay",
|
||||||
ApiKeyID: epayKey.ID,
|
ApiKeyID: epayKey.ID,
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(order).Error; err != nil {
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
@@ -550,7 +573,7 @@ func TestSendOrderCallbackEpayUsesApiKeySecretByPid(t *testing.T) {
|
|||||||
Status: mdb.StatusPaySuccess,
|
Status: mdb.StatusPaySuccess,
|
||||||
NotifyUrl: server.URL,
|
NotifyUrl: server.URL,
|
||||||
BlockTransactionId: "block_epay_sign",
|
BlockTransactionId: "block_epay_sign",
|
||||||
PaymentType: mdb.PaymentTypeEpay,
|
PaymentType: "epay",
|
||||||
ApiKeyID: key.ID,
|
ApiKeyID: key.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/GMWalletApp/epusdt/util/constant"
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
appLog "github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -762,6 +763,34 @@ func TestAdminOrders_CloseNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminOrders_CloseWaitSelectOrder(t *testing.T) {
|
||||||
|
e, token := setupAdminTestEnv(t)
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: "trade-admin-close-wait-select",
|
||||||
|
OrderId: "order-admin-close-wait-select",
|
||||||
|
Amount: 10,
|
||||||
|
Currency: "CNY",
|
||||||
|
Status: mdb.StatusWaitSelect,
|
||||||
|
NotifyUrl: "https://merchant.example/notify",
|
||||||
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("create wait-select order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/close", nil, token)
|
||||||
|
t.Logf("CloseOrder wait-select: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
assertOK(t, rec)
|
||||||
|
|
||||||
|
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload order: %v", err)
|
||||||
|
}
|
||||||
|
if reloaded.Status != mdb.StatusExpired {
|
||||||
|
t.Fatalf("status = %d, want %d", reloaded.Status, mdb.StatusExpired)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAdminOrders_MarkPaidNotFound verifies mark-paid on non-existent order.
|
// TestAdminOrders_MarkPaidNotFound verifies mark-paid on non-existent order.
|
||||||
func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
|
func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
|
||||||
e, token := setupAdminTestEnv(t)
|
e, token := setupAdminTestEnv(t)
|
||||||
@@ -1441,6 +1470,90 @@ func TestAdminSettings_AmountPrecisionValidationAndListing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminSettings_LogLevelValidationRuntimeUpdateAndReset(t *testing.T) {
|
||||||
|
e, token := setupAdminTestEnv(t)
|
||||||
|
oldLevel := appLog.CurrentLevel()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = appLog.SetLevel(oldLevel)
|
||||||
|
})
|
||||||
|
if err := appLog.SetLevel(mdb.SettingDefaultSystemLogLevel); err != nil {
|
||||||
|
t.Fatalf("reset log level: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||||
|
"items": []map[string]interface{}{
|
||||||
|
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": " DEBUG ", "type": "string"},
|
||||||
|
},
|
||||||
|
}, token)
|
||||||
|
resp := assertOK(t, rec)
|
||||||
|
results, ok := resp["data"].([]interface{})
|
||||||
|
if !ok || len(results) != 1 {
|
||||||
|
t.Fatalf("expected one result, got %T %v", resp["data"], resp["data"])
|
||||||
|
}
|
||||||
|
result, _ := results[0].(map[string]interface{})
|
||||||
|
if result["ok"] != true {
|
||||||
|
t.Fatalf("system.log_level upsert result = %v", result)
|
||||||
|
}
|
||||||
|
if got := appLog.CurrentLevel(); got != "debug" {
|
||||||
|
t.Fatalf("runtime log level = %q, want debug", got)
|
||||||
|
}
|
||||||
|
if got := data.GetSettingString(mdb.SettingKeySystemLogLevel, ""); got != "debug" {
|
||||||
|
t.Fatalf("stored system.log_level = %q, want debug", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||||
|
"items": []map[string]interface{}{
|
||||||
|
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": "fatal", "type": "string"},
|
||||||
|
{"group": "rate", "key": mdb.SettingKeySystemLogLevel, "value": "info", "type": "string"},
|
||||||
|
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": "", "type": "string"},
|
||||||
|
},
|
||||||
|
}, 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"])
|
||||||
|
}
|
||||||
|
for _, item := range results {
|
||||||
|
result, _ := item.(map[string]interface{})
|
||||||
|
if result["ok"] != false {
|
||||||
|
t.Fatalf("invalid system.log_level result = %v, want ok=false", result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := appLog.CurrentLevel(); got != "debug" {
|
||||||
|
t.Fatalf("invalid upsert changed runtime log level to %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doDeleteAdmin(e, "/admin/api/v1/settings/"+mdb.SettingKeySystemLogLevel, token)
|
||||||
|
assertOK(t, rec)
|
||||||
|
if got := appLog.CurrentLevel(); got != mdb.SettingDefaultSystemLogLevel {
|
||||||
|
t.Fatalf("runtime log level after delete = %q, want %q", got, mdb.SettingDefaultSystemLogLevel)
|
||||||
|
}
|
||||||
|
if got := data.GetSettingString(mdb.SettingKeySystemLogLevel, mdb.SettingDefaultSystemLogLevel); got != mdb.SettingDefaultSystemLogLevel {
|
||||||
|
t.Fatalf("deleted system.log_level fallback = %q, want %q", got, mdb.SettingDefaultSystemLogLevel)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec = doPutAdmin(e, "/admin/api/v1/settings", map[string]interface{}{
|
||||||
|
"items": []map[string]interface{}{
|
||||||
|
{"group": "system", "key": mdb.SettingKeySystemLogLevel, "value": "warn", "type": "string"},
|
||||||
|
},
|
||||||
|
}, token)
|
||||||
|
assertOK(t, rec)
|
||||||
|
if got := appLog.CurrentLevel(); got != "warn" {
|
||||||
|
t.Fatalf("runtime log level after restore = %q, want warn", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var restored mdb.Setting
|
||||||
|
if err := dao.Mdb.Unscoped().Where("`key` = ?", mdb.SettingKeySystemLogLevel).Take(&restored).Error; err != nil {
|
||||||
|
t.Fatalf("load restored system.log_level unscoped: %v", err)
|
||||||
|
}
|
||||||
|
if restored.DeletedAt.Valid {
|
||||||
|
t.Fatalf("restored system.log_level still has deleted_at=%v", restored.DeletedAt)
|
||||||
|
}
|
||||||
|
if restored.Value != "warn" {
|
||||||
|
t.Fatalf("restored system.log_level value = %q, want warn", restored.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAdminSettings_DeleteNonExistent verifies deleting a non-existent setting.
|
// TestAdminSettings_DeleteNonExistent verifies deleting a non-existent setting.
|
||||||
func TestAdminSettings_DeleteNonExistent(t *testing.T) {
|
func TestAdminSettings_DeleteNonExistent(t *testing.T) {
|
||||||
e, token := setupAdminTestEnv(t)
|
e, token := setupAdminTestEnv(t)
|
||||||
|
|||||||
+22
-4
@@ -8,6 +8,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/controller/admin"
|
"github.com/GMWalletApp/epusdt/controller/admin"
|
||||||
"github.com/GMWalletApp/epusdt/controller/comm"
|
"github.com/GMWalletApp/epusdt/controller/comm"
|
||||||
@@ -43,7 +44,9 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
// payment routes
|
// payment routes
|
||||||
paymentRoute := e.Group("/payments")
|
paymentRoute := e.Group("/payments")
|
||||||
|
|
||||||
// gmpay v1 routes
|
// gmpay v1 routes. CreateTransaction accepts token+network together
|
||||||
|
// for a concrete chain order, or omits both for a status=4 placeholder
|
||||||
|
// that is completed later by /pay/switch-network or routed to OkPay.
|
||||||
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
||||||
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
||||||
gmpayV1.GET("/config", comm.Ctrl.GetPublicConfig)
|
gmpayV1.GET("/config", comm.Ctrl.GetPublicConfig)
|
||||||
@@ -129,6 +132,21 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
notifyURL := getString(params, "notify_url")
|
notifyURL := getString(params, "notify_url")
|
||||||
outTradeNo := getString(params, "out_trade_no")
|
outTradeNo := getString(params, "out_trade_no")
|
||||||
returnURL := getString(params, "return_url")
|
returnURL := getString(params, "return_url")
|
||||||
|
token := strings.TrimSpace(getString(params, "token"))
|
||||||
|
if token == "" {
|
||||||
|
token = data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "")
|
||||||
|
}
|
||||||
|
network := strings.TrimSpace(getString(params, "network"))
|
||||||
|
if network == "" {
|
||||||
|
network = data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "")
|
||||||
|
}
|
||||||
|
currency := strings.TrimSpace(getString(params, "currency"))
|
||||||
|
if currency == "" {
|
||||||
|
currency = data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "")
|
||||||
|
}
|
||||||
|
if currency == "" {
|
||||||
|
currency = "cny"
|
||||||
|
}
|
||||||
|
|
||||||
amountFloat, err := strconv.ParseFloat(money, 64)
|
amountFloat, err := strconv.ParseFloat(money, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -136,9 +154,9 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body := map[string]interface{}{
|
body := map[string]interface{}{
|
||||||
"token": data.GetSettingString(mdb.SettingKeyEpayDefaultToken, "usdt"),
|
"token": token,
|
||||||
"currency": data.GetSettingString(mdb.SettingKeyEpayDefaultCurrency, "cny"),
|
"currency": currency,
|
||||||
"network": data.GetSettingString(mdb.SettingKeyEpayDefaultNetwork, "tron"),
|
"network": network,
|
||||||
"amount": amountFloat,
|
"amount": amountFloat,
|
||||||
"notify_url": notifyURL,
|
"notify_url": notifyURL,
|
||||||
"order_id": outTradeNo,
|
"order_id": outTradeNo,
|
||||||
|
|||||||
+656
-1
@@ -26,6 +26,7 @@ import (
|
|||||||
"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"
|
||||||
|
"go.uber.org/zap"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,8 +47,13 @@ func setupTestEnv(t *testing.T) *echo.Echo {
|
|||||||
viper.Set("log_save_path", tmpDir)
|
viper.Set("log_save_path", tmpDir)
|
||||||
viper.Set("sqlite_database_filename", tmpDir+"/test.db")
|
viper.Set("sqlite_database_filename", tmpDir+"/test.db")
|
||||||
viper.Set("runtime_sqlite_filename", tmpDir+"/runtime.db")
|
viper.Set("runtime_sqlite_filename", tmpDir+"/runtime.db")
|
||||||
|
config.LogLevel = "info"
|
||||||
|
config.LogSavePath = tmpDir
|
||||||
|
|
||||||
log.Init()
|
log.Sugar = zap.NewNop().Sugar()
|
||||||
|
if err := log.SetLevel(config.LogLevel); err != nil {
|
||||||
|
t.Fatalf("set test log level: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// init config paths
|
// init config paths
|
||||||
os.Setenv("EPUSDT_CONFIG", tmpDir)
|
os.Setenv("EPUSDT_CONFIG", tmpDir)
|
||||||
@@ -413,6 +419,168 @@ func TestCreateOrderGmpayV1FormData(t *testing.T) {
|
|||||||
t.Logf("Form-data order created: trade_id=%v", data["trade_id"])
|
t.Logf("Form-data order created: trade_id=%v", data["trade_id"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateOrderGmpayV1PlaceholderWithoutTokenNetwork(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
body := signBody(map[string]interface{}{
|
||||||
|
"order_id": "test-placeholder-001",
|
||||||
|
"amount": 1.00,
|
||||||
|
"currency": "cny",
|
||||||
|
"notify_url": "https://93.184.216.34/notify",
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
resp := parseResp(t, rec)
|
||||||
|
respData, ok := resp["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data in response, got: %v", resp)
|
||||||
|
}
|
||||||
|
tradeID, _ := respData["trade_id"].(string)
|
||||||
|
if tradeID == "" {
|
||||||
|
t.Fatalf("missing trade_id in response: %v", respData)
|
||||||
|
}
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload placeholder order: %v", err)
|
||||||
|
}
|
||||||
|
if order.PaymentType != mdb.PaymentTypeGmpay {
|
||||||
|
t.Fatalf("placeholder order payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeGmpay)
|
||||||
|
}
|
||||||
|
if got := int(respData["status"].(float64)); got != mdb.StatusWaitSelect {
|
||||||
|
t.Fatalf("status = %d, want %d", got, mdb.StatusWaitSelect)
|
||||||
|
}
|
||||||
|
if respData["token"] != "" || respData["receive_address"] != "" || respData["actual_amount"].(float64) != 0 {
|
||||||
|
t.Fatalf("placeholder chain fields = %#v", respData)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkoutData := getCheckoutCounterRespData(t, e, tradeID)
|
||||||
|
if got := int(checkoutData["status"].(float64)); got != mdb.StatusWaitSelect {
|
||||||
|
t.Fatalf("checkout status = %d, want %d", got, mdb.StatusWaitSelect)
|
||||||
|
}
|
||||||
|
if checkoutData["actual_amount"].(float64) != 0 || checkoutData["token"] != "" || checkoutData["network"] != "" || checkoutData["receive_address"] != "" {
|
||||||
|
t.Fatalf("placeholder checkout chain fields = %#v", checkoutData)
|
||||||
|
}
|
||||||
|
if checkoutData["payment_type"] != "gmpay" {
|
||||||
|
t.Fatalf("placeholder checkout payment_type = %v, want gmpay", checkoutData["payment_type"])
|
||||||
|
}
|
||||||
|
if checkoutData["is_selected"].(bool) {
|
||||||
|
t.Fatalf("placeholder checkout is_selected = true, want false")
|
||||||
|
}
|
||||||
|
|
||||||
|
statusReq := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
|
||||||
|
statusRec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(statusRec, statusReq)
|
||||||
|
if statusRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("check-status expected 200, got %d: %s", statusRec.Code, statusRec.Body.String())
|
||||||
|
}
|
||||||
|
statusResp := parseResp(t, statusRec)
|
||||||
|
statusData := statusResp["data"].(map[string]interface{})
|
||||||
|
if got := int(statusData["status"].(float64)); got != mdb.StatusWaitSelect {
|
||||||
|
t.Fatalf("check-status status = %d, want %d", got, mdb.StatusWaitSelect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateOrderGmpayV1RejectsPartialTokenNetwork(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
body := signBody(map[string]interface{}{
|
||||||
|
"order_id": "test-placeholder-partial-001",
|
||||||
|
"amount": 1.00,
|
||||||
|
"currency": "cny",
|
||||||
|
"network": "tron",
|
||||||
|
"notify_url": "https://93.184.216.34/notify",
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
resp := parseResp(t, rec)
|
||||||
|
if got := int(resp["status_code"].(float64)); got != 10009 {
|
||||||
|
t.Fatalf("status_code = %d, want 10009; response=%v", got, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchNetworkCompletesGmpayPlaceholderInPlace(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
createBody := signBody(map[string]interface{}{
|
||||||
|
"order_id": "test-placeholder-switch-001",
|
||||||
|
"amount": 1.00,
|
||||||
|
"currency": "cny",
|
||||||
|
"notify_url": "https://93.184.216.34/notify",
|
||||||
|
})
|
||||||
|
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
|
||||||
|
if createRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create placeholder failed: %d %s", createRec.Code, createRec.Body.String())
|
||||||
|
}
|
||||||
|
createResp := parseResp(t, createRec)
|
||||||
|
tradeID, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
|
||||||
|
if tradeID == "" {
|
||||||
|
t.Fatal("missing trade_id in create response")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||||
|
"trade_id": tradeID,
|
||||||
|
"token": "USDT",
|
||||||
|
"network": "tron",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("switch placeholder failed: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
resp := parseResp(t, rec)
|
||||||
|
respData, ok := resp["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data in switch response, got: %v", resp)
|
||||||
|
}
|
||||||
|
if got, _ := respData["trade_id"].(string); got != tradeID {
|
||||||
|
t.Fatalf("switch trade_id = %q, want parent %q", got, tradeID)
|
||||||
|
}
|
||||||
|
if got := int(respData["status"].(float64)); got != mdb.StatusWaitPay {
|
||||||
|
t.Fatalf("switch status = %d, want %d", got, mdb.StatusWaitPay)
|
||||||
|
}
|
||||||
|
if got, _ := respData["is_selected"].(bool); got {
|
||||||
|
t.Fatalf("switch is_selected = %v, want false", respData["is_selected"])
|
||||||
|
}
|
||||||
|
if respData["payment_url"] != "" {
|
||||||
|
t.Fatalf("switch payment_url = %v, want empty for unselected chain parent", respData["payment_url"])
|
||||||
|
}
|
||||||
|
if respData["payment_type"] != "gmpay" {
|
||||||
|
t.Fatalf("switch payment_type = %v, want gmpay", respData["payment_type"])
|
||||||
|
}
|
||||||
|
if respData["network"] != "tron" || respData["token"] != "USDT" || respData["receive_address"] != "TTestTronAddress001" {
|
||||||
|
t.Fatalf("switch chain fields = %#v", respData)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectRec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||||
|
"trade_id": tradeID,
|
||||||
|
"token": "USDT",
|
||||||
|
"network": "tron",
|
||||||
|
})
|
||||||
|
if selectRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("select completed parent failed: %d %s", selectRec.Code, selectRec.Body.String())
|
||||||
|
}
|
||||||
|
selectResp := parseResp(t, selectRec)
|
||||||
|
selectData, _ := selectResp["data"].(map[string]interface{})
|
||||||
|
if got, _ := selectData["trade_id"].(string); got != tradeID {
|
||||||
|
t.Fatalf("select trade_id = %q, want parent %q", got, tradeID)
|
||||||
|
}
|
||||||
|
if got, _ := selectData["is_selected"].(bool); !got {
|
||||||
|
t.Fatalf("select is_selected = %v, want true", selectData["is_selected"])
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := data.CountActiveSubOrders(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count sub orders: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("active sub-order count = %d, want 0", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getSupportedNetworks is a helper that calls GET /payments/gmpay/v1/config
|
// getSupportedNetworks is a helper that calls GET /payments/gmpay/v1/config
|
||||||
// and returns a map of network → []token for easy assertions.
|
// and returns a map of network → []token for easy assertions.
|
||||||
func getSupportedNetworks(t *testing.T, e *echo.Echo) map[string][]string {
|
func getSupportedNetworks(t *testing.T, e *echo.Echo) map[string][]string {
|
||||||
@@ -543,6 +711,12 @@ func TestGetPublicConfig(t *testing.T) {
|
|||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
config.BuildVersion = oldVersion
|
config.BuildVersion = oldVersion
|
||||||
})
|
})
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed epay.default_token: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed epay.default_network: %v", err)
|
||||||
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -659,6 +833,31 @@ func TestGetPublicConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetPublicConfig_EpayDefaultsReturnEmptyAfterDelete(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
if err := data.DeleteSetting(mdb.SettingKeyEpayDefaultToken); err != nil {
|
||||||
|
t.Fatalf("delete epay.default_token: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.DeleteSetting(mdb.SettingKeyEpayDefaultNetwork); err != nil {
|
||||||
|
t.Fatalf("delete epay.default_network: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/config", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
resp := parseResp(t, rec)
|
||||||
|
respData := resp["data"].(map[string]interface{})
|
||||||
|
epay := respData["epay"].(map[string]interface{})
|
||||||
|
if epay["default_token"] != "" || epay["default_network"] != "" {
|
||||||
|
t.Fatalf("epay defaults = token %v network %v, want empty", epay["default_token"], epay["default_network"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetPublicConfig_BrandLegacyFallback(t *testing.T) {
|
func TestGetPublicConfig_BrandLegacyFallback(t *testing.T) {
|
||||||
e := setupTestEnv(t)
|
e := setupTestEnv(t)
|
||||||
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandSiteName, "legacy cashier", mdb.SettingTypeString); err != nil {
|
if err := data.SetSetting(mdb.SettingGroupBrand, mdb.SettingKeyBrandSiteName, "legacy cashier", mdb.SettingTypeString); err != nil {
|
||||||
@@ -1004,6 +1203,13 @@ func TestCreateOrderNetworkIsolation(t *testing.T) {
|
|||||||
func TestEpaySubmitPhpGetCompatible(t *testing.T) {
|
func TestEpaySubmitPhpGetCompatible(t *testing.T) {
|
||||||
e := setupTestEnv(t)
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed epay.default_token: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed epay.default_network: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
values := signEpayValues(url.Values{
|
values := signEpayValues(url.Values{
|
||||||
"pid": {"1"},
|
"pid": {"1"},
|
||||||
"name": {"epay-get-001"},
|
"name": {"epay-get-001"},
|
||||||
@@ -1024,6 +1230,141 @@ func TestEpaySubmitPhpGetCompatible(t *testing.T) {
|
|||||||
if !strings.HasPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/") {
|
if !strings.HasPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/") {
|
||||||
t.Fatalf("expected checkout redirect, got %q", rec.Header().Get("Location"))
|
t.Fatalf("expected checkout redirect, got %q", rec.Header().Get("Location"))
|
||||||
}
|
}
|
||||||
|
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload epay order: %v", err)
|
||||||
|
}
|
||||||
|
if order.Status != mdb.StatusWaitPay || order.Token != "USDT" || order.Network != mdb.NetworkTron || order.ReceiveAddress == "" || order.ActualAmount <= 0 {
|
||||||
|
t.Fatalf("epay order should be concrete status=1 chain order, got status=%d token=%q network=%q address=%q actual=%.4f", order.Status, order.Token, order.Network, order.ReceiveAddress, order.ActualAmount)
|
||||||
|
}
|
||||||
|
if order.PaymentType != mdb.PaymentTypeEpay {
|
||||||
|
t.Fatalf("epay payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
|
||||||
|
}
|
||||||
|
checkoutData := getCheckoutCounterRespData(t, e, tradeID)
|
||||||
|
if checkoutData["payment_type"] != "epay" {
|
||||||
|
t.Fatalf("epay checkout payment_type = %v, want epay", checkoutData["payment_type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEpaySubmitPhpRequestTokenNetworkOverrideDefaults(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "usdt", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed epay.default_token: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed epay.default_network: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := signEpayValues(url.Values{
|
||||||
|
"pid": {"1"},
|
||||||
|
"name": {"epay-override-001"},
|
||||||
|
"type": {"alipay"},
|
||||||
|
"money": {"1.00"},
|
||||||
|
"out_trade_no": {"epay-override-001"},
|
||||||
|
"notify_url": {"https://93.184.216.34/notify"},
|
||||||
|
"return_url": {"http://localhost/return"},
|
||||||
|
"token": {"usdt"},
|
||||||
|
"network": {"solana"},
|
||||||
|
"currency": {"cny"},
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload epay order: %v", err)
|
||||||
|
}
|
||||||
|
if order.Network != mdb.NetworkSolana || order.ReceiveAddress != "SolTestAddress001" || order.Token != "USDT" {
|
||||||
|
t.Fatalf("epay override order fields = network %q address %q token %q", order.Network, order.ReceiveAddress, order.Token)
|
||||||
|
}
|
||||||
|
if order.PaymentType != mdb.PaymentTypeEpay {
|
||||||
|
t.Fatalf("payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEpaySubmitPhpWithoutTokenNetworkDefaultsCreatesPlaceholder(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("clear epay.default_token: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("clear epay.default_network: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := signEpayValues(url.Values{
|
||||||
|
"pid": {"1"},
|
||||||
|
"name": {"epay-placeholder-001"},
|
||||||
|
"type": {"alipay"},
|
||||||
|
"money": {"1.00"},
|
||||||
|
"out_trade_no": {"epay-placeholder-001"},
|
||||||
|
"notify_url": {"https://93.184.216.34/notify"},
|
||||||
|
"return_url": {"http://localhost/return"},
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("expected 302, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
tradeID := strings.TrimPrefix(rec.Header().Get("Location"), "/pay/checkout-counter/")
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload epay placeholder: %v", err)
|
||||||
|
}
|
||||||
|
if order.Status != mdb.StatusWaitSelect || order.Token != "" || order.Network != "" || order.ReceiveAddress != "" || order.ActualAmount != 0 {
|
||||||
|
t.Fatalf("epay placeholder fields = status %d token %q network %q address %q actual %v", order.Status, order.Token, order.Network, order.ReceiveAddress, order.ActualAmount)
|
||||||
|
}
|
||||||
|
if order.PaymentType != mdb.PaymentTypeEpay {
|
||||||
|
t.Fatalf("payment_type = %q, want %q", order.PaymentType, mdb.PaymentTypeEpay)
|
||||||
|
}
|
||||||
|
checkoutData := getCheckoutCounterRespData(t, e, tradeID)
|
||||||
|
if checkoutData["payment_type"] != "epay" || int(checkoutData["status"].(float64)) != mdb.StatusWaitSelect {
|
||||||
|
t.Fatalf("checkout placeholder data = %#v", checkoutData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEpaySubmitPhpRejectsPartialResolvedTokenNetwork(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("clear epay.default_token: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "tron", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed epay.default_network: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := signEpayValues(url.Values{
|
||||||
|
"pid": {"1"},
|
||||||
|
"name": {"epay-partial-001"},
|
||||||
|
"type": {"alipay"},
|
||||||
|
"money": {"1.00"},
|
||||||
|
"out_trade_no": {"epay-partial-001"},
|
||||||
|
"notify_url": {"https://93.184.216.34/notify"},
|
||||||
|
"return_url": {"http://localhost/return"},
|
||||||
|
})
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d body=%s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
resp := parseResp(t, rec)
|
||||||
|
if got := int(resp["status_code"].(float64)); got != 10009 {
|
||||||
|
t.Fatalf("status_code = %d, want 10009", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
|
func TestEpaySubmitPhpPostFormCompatible(t *testing.T) {
|
||||||
@@ -1393,6 +1734,22 @@ func TestCheckoutCounterResp_ReturnsPaidOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckoutCounterResp_NormalizesPaymentTypeCase(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-ptype-case-001")
|
||||||
|
|
||||||
|
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Update("payment_type", "epay").Error; err != nil {
|
||||||
|
t.Fatalf("set lowercase payment_type: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := getCheckoutCounterRespData(t, e, tradeID)
|
||||||
|
if data["payment_type"] != "epay" {
|
||||||
|
t.Fatalf("payment_type = %v, want epay", data["payment_type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckoutCounterResp_ReturnsExpiredOrder(t *testing.T) {
|
func TestCheckoutCounterResp_ReturnsExpiredOrder(t *testing.T) {
|
||||||
e := setupTestEnv(t)
|
e := setupTestEnv(t)
|
||||||
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-expired-001")
|
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-expired-001")
|
||||||
@@ -1684,6 +2041,304 @@ func TestSwitchNetwork_OkPayCreatesProviderSubOrder(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSwitchNetwork_OkPayFromWaitSelectPlaceholder(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayEnabled, "true", mdb.SettingTypeBool); err != nil {
|
||||||
|
t.Fatalf("seed okpay.enabled: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, "shop-placeholder", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay.shop_id: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, "token-placeholder", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay.shop_token: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayCallbackURL, "https://example.com/okpay/notify", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay.callback_url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origFactory := http_client.ClientFactory
|
||||||
|
http_client.ClientFactory = func() *resty.Client {
|
||||||
|
return stubRestyClient(func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.String() != "https://api.okaypay.me/shop/payLink" {
|
||||||
|
t.Fatalf("unexpected okpay URL: %s", req.URL.String())
|
||||||
|
}
|
||||||
|
if err := req.ParseForm(); err != nil {
|
||||||
|
t.Fatalf("parse form: %v", err)
|
||||||
|
}
|
||||||
|
if got := req.Form.Get("coin"); got != "USDT" {
|
||||||
|
t.Fatalf("coin = %q, want USDT", got)
|
||||||
|
}
|
||||||
|
body := `{"status":"success","code":200,"data":{"order_id":"okp-placeholder-1","pay_url":"https://pay.okaypay.test/placeholder"}}`
|
||||||
|
header := make(http.Header)
|
||||||
|
header.Set("Content-Type", "application/json")
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: header,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
http_client.ClientFactory = origFactory
|
||||||
|
})
|
||||||
|
|
||||||
|
createBody := signBody(map[string]interface{}{
|
||||||
|
"order_id": "switch-okpay-placeholder-001",
|
||||||
|
"amount": 1.00,
|
||||||
|
"currency": "cny",
|
||||||
|
"notify_url": "https://93.184.216.34/notify",
|
||||||
|
})
|
||||||
|
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", createBody)
|
||||||
|
if createRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create placeholder failed: %d %s", createRec.Code, createRec.Body.String())
|
||||||
|
}
|
||||||
|
createResp := parseResp(t, createRec)
|
||||||
|
tradeID, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
|
||||||
|
if tradeID == "" {
|
||||||
|
t.Fatal("missing parent trade_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||||
|
"trade_id": tradeID,
|
||||||
|
"token": "USDT",
|
||||||
|
"network": "okpay",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("switch placeholder to okpay failed: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
resp := parseResp(t, rec)
|
||||||
|
dataMap, _ := resp["data"].(map[string]interface{})
|
||||||
|
if dataMap["payment_url"] != "https://pay.okaypay.test/placeholder" {
|
||||||
|
t.Fatalf("payment_url = %v, want okpay url", dataMap["payment_url"])
|
||||||
|
}
|
||||||
|
if got, _ := dataMap["trade_id"].(string); got != tradeID {
|
||||||
|
t.Fatalf("switch trade_id = %q, want parent %q", got, tradeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
parent, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load parent order: %v", err)
|
||||||
|
}
|
||||||
|
if parent.Status != mdb.StatusWaitPay {
|
||||||
|
t.Fatalf("parent status = %d, want %d", parent.Status, mdb.StatusWaitPay)
|
||||||
|
}
|
||||||
|
if parent.IsSelected {
|
||||||
|
t.Fatal("parent is_selected = true, want false")
|
||||||
|
}
|
||||||
|
if parent.PayProvider != mdb.PaymentProviderOkPay || parent.Token != "USDT" || parent.Network != mdb.PaymentProviderOkPay || parent.ReceiveAddress != "OKPAY" || parent.ActualAmount <= 0 {
|
||||||
|
t.Fatalf("okpay placeholder parent fields = provider=%q token=%q network=%q address=%q actual=%v", parent.PayProvider, parent.Token, parent.Network, parent.ReceiveAddress, parent.ActualAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(tradeID, mdb.PaymentProviderOkPay)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load provider row: %v", err)
|
||||||
|
}
|
||||||
|
if providerRow.ProviderOrderID != "okp-placeholder-1" || providerRow.PayURL != "https://pay.okaypay.test/placeholder" {
|
||||||
|
t.Fatalf("provider row = order_id %q pay_url %q", providerRow.ProviderOrderID, providerRow.PayURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
var lockCount int64
|
||||||
|
if err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Count(&lockCount).Error; err != nil {
|
||||||
|
t.Fatalf("count transaction locks: %v", err)
|
||||||
|
}
|
||||||
|
if lockCount != 0 {
|
||||||
|
t.Fatalf("transaction lock count = %d, want 0", lockCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
checkoutData := getCheckoutCounterRespData(t, e, tradeID)
|
||||||
|
if checkoutData["payment_url"] != "https://pay.okaypay.test/placeholder" {
|
||||||
|
t.Fatalf("checkout payment_url = %v, want okpay url", checkoutData["payment_url"])
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := data.CountActiveSubOrders(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count active sub-orders: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("active sub-order count after first okpay switch = %d, want 0", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
secondRec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||||
|
"trade_id": tradeID,
|
||||||
|
"token": "USDT",
|
||||||
|
"network": "solana",
|
||||||
|
})
|
||||||
|
if secondRec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("switch okpay parent to solana failed: %d %s", secondRec.Code, secondRec.Body.String())
|
||||||
|
}
|
||||||
|
secondResp := parseResp(t, secondRec)
|
||||||
|
secondData, _ := secondResp["data"].(map[string]interface{})
|
||||||
|
subTradeID, _ := secondData["trade_id"].(string)
|
||||||
|
if subTradeID == "" || subTradeID == tradeID {
|
||||||
|
t.Fatalf("expected chain child trade_id, got %q", subTradeID)
|
||||||
|
}
|
||||||
|
subOrder, err := data.GetOrderInfoByTradeId(subTradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load chain sub-order: %v", err)
|
||||||
|
}
|
||||||
|
if subOrder.ParentTradeId != tradeID || subOrder.PayProvider != mdb.PaymentProviderOnChain || subOrder.Network != mdb.NetworkSolana {
|
||||||
|
t.Fatalf("sub-order parent/provider/network = %q/%q/%q", subOrder.ParentTradeId, subOrder.PayProvider, subOrder.Network)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchNetwork_EpayPlaceholderCompletesChainInPlace(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("clear epay.default_token: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("clear epay.default_network: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
values := signEpayValues(url.Values{
|
||||||
|
"pid": {"1"},
|
||||||
|
"name": {"epay-switch-chain-ph-001"},
|
||||||
|
"type": {"alipay"},
|
||||||
|
"money": {"1.00"},
|
||||||
|
"out_trade_no": {"epay-switch-chain-ph-001"},
|
||||||
|
"notify_url": {"https://93.184.216.34/notify"},
|
||||||
|
"return_url": {"http://localhost/return"},
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
|
||||||
|
createRec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(createRec, req)
|
||||||
|
if createRec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("create epay placeholder failed: %d %s", createRec.Code, createRec.Body.String())
|
||||||
|
}
|
||||||
|
tradeID := strings.TrimPrefix(createRec.Header().Get("Location"), "/pay/checkout-counter/")
|
||||||
|
|
||||||
|
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||||
|
"trade_id": tradeID,
|
||||||
|
"token": "USDT",
|
||||||
|
"network": "tron",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("switch epay placeholder to chain failed: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
resp := parseResp(t, rec)
|
||||||
|
respData, _ := resp["data"].(map[string]interface{})
|
||||||
|
if got, _ := respData["trade_id"].(string); got != tradeID {
|
||||||
|
t.Fatalf("switch trade_id = %q, want parent %q", got, tradeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
parent, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load parent: %v", err)
|
||||||
|
}
|
||||||
|
if parent.Status != mdb.StatusWaitPay || parent.PaymentType != mdb.PaymentTypeEpay || parent.Network != mdb.NetworkTron || parent.Token != "USDT" || parent.ReceiveAddress == "" {
|
||||||
|
t.Fatalf("parent fields = status %d payment_type %q network %q token %q address %q", parent.Status, parent.PaymentType, parent.Network, parent.Token, parent.ReceiveAddress)
|
||||||
|
}
|
||||||
|
count, err := data.CountActiveSubOrders(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count active sub-orders: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("active sub-order count = %d, want 0", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchNetwork_OkPayFromEpayWaitSelectPlaceholder(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultToken, "", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("clear epay.default_token: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupEpay, mdb.SettingKeyEpayDefaultNetwork, "", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("clear epay.default_network: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayEnabled, "true", mdb.SettingTypeBool); err != nil {
|
||||||
|
t.Fatalf("seed okpay.enabled: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopID, "shop-epay-placeholder", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay.shop_id: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayShopToken, "token-epay-placeholder", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay.shop_token: %v", err)
|
||||||
|
}
|
||||||
|
if err := data.SetSetting(mdb.SettingGroupOkPay, mdb.SettingKeyOkPayCallbackURL, "https://example.com/okpay/notify", mdb.SettingTypeString); err != nil {
|
||||||
|
t.Fatalf("seed okpay.callback_url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origFactory := http_client.ClientFactory
|
||||||
|
http_client.ClientFactory = func() *resty.Client {
|
||||||
|
return stubRestyClient(func(req *http.Request) (*http.Response, error) {
|
||||||
|
body := `{"status":"success","code":200,"data":{"order_id":"okp-epay-placeholder-1","pay_url":"https://pay.okaypay.test/epay-placeholder"}}`
|
||||||
|
header := make(http.Header)
|
||||||
|
header.Set("Content-Type", "application/json")
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Status: "200 OK",
|
||||||
|
Header: header,
|
||||||
|
Body: io.NopCloser(strings.NewReader(body)),
|
||||||
|
}, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
http_client.ClientFactory = origFactory
|
||||||
|
})
|
||||||
|
|
||||||
|
values := signEpayValues(url.Values{
|
||||||
|
"pid": {"1"},
|
||||||
|
"name": {"epay-switch-okpay-ph-001"},
|
||||||
|
"type": {"alipay"},
|
||||||
|
"money": {"1.00"},
|
||||||
|
"out_trade_no": {"epay-switch-okpay-ph-001"},
|
||||||
|
"notify_url": {"https://93.184.216.34/notify"},
|
||||||
|
"return_url": {"http://localhost/return"},
|
||||||
|
})
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/payments/epay/v1/order/create-transaction/submit.php?"+values.Encode(), nil)
|
||||||
|
createRec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(createRec, req)
|
||||||
|
if createRec.Code != http.StatusFound {
|
||||||
|
t.Fatalf("create epay placeholder failed: %d %s", createRec.Code, createRec.Body.String())
|
||||||
|
}
|
||||||
|
tradeID := strings.TrimPrefix(createRec.Header().Get("Location"), "/pay/checkout-counter/")
|
||||||
|
|
||||||
|
rec := doPost(e, "/pay/switch-network", map[string]interface{}{
|
||||||
|
"trade_id": tradeID,
|
||||||
|
"token": "USDT",
|
||||||
|
"network": "okpay",
|
||||||
|
})
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("switch epay placeholder to okpay failed: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
resp := parseResp(t, rec)
|
||||||
|
respData, _ := resp["data"].(map[string]interface{})
|
||||||
|
if got, _ := respData["trade_id"].(string); got != tradeID {
|
||||||
|
t.Fatalf("switch trade_id = %q, want parent %q", got, tradeID)
|
||||||
|
}
|
||||||
|
if respData["payment_url"] != "https://pay.okaypay.test/epay-placeholder" {
|
||||||
|
t.Fatalf("payment_url = %v, want okpay url", respData["payment_url"])
|
||||||
|
}
|
||||||
|
|
||||||
|
parent, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load parent: %v", err)
|
||||||
|
}
|
||||||
|
if parent.PaymentType != mdb.PaymentTypeEpay || parent.PayProvider != mdb.PaymentProviderOkPay || parent.Network != mdb.PaymentProviderOkPay || parent.ReceiveAddress != "OKPAY" {
|
||||||
|
t.Fatalf("parent fields = payment_type %q provider %q network %q address %q", parent.PaymentType, parent.PayProvider, parent.Network, parent.ReceiveAddress)
|
||||||
|
}
|
||||||
|
count, err := data.CountActiveSubOrders(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("count active sub-orders: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("active sub-order count = %d, want 0", count)
|
||||||
|
}
|
||||||
|
providerRow, err := data.GetProviderOrderByTradeIDAndProvider(tradeID, mdb.PaymentProviderOkPay)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load provider row: %v", err)
|
||||||
|
}
|
||||||
|
if providerRow.ProviderOrderID != "okp-epay-placeholder-1" {
|
||||||
|
t.Fatalf("provider_order_id = %q", providerRow.ProviderOrderID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSwitchNetwork_OkPayIntegration(t *testing.T) {
|
func TestSwitchNetwork_OkPayIntegration(t *testing.T) {
|
||||||
shopID := strings.TrimSpace(os.Getenv("EPUSDT_OKPAY_ID"))
|
shopID := strings.TrimSpace(os.Getenv("EPUSDT_OKPAY_ID"))
|
||||||
if shopID == "" {
|
if shopID == "" {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func Start() {
|
|||||||
go StartPolygonWebSocketListener()
|
go StartPolygonWebSocketListener()
|
||||||
go StartPlasmaWebSocketListener()
|
go StartPlasmaWebSocketListener()
|
||||||
go StartTronBlockScannerListener()
|
go StartTronBlockScannerListener()
|
||||||
|
go StartTonBlockScannerListener()
|
||||||
|
|
||||||
c := cron.New()
|
c := cron.New()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,574 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
|
"github.com/xssnick/tonutils-go/ton"
|
||||||
|
"github.com/xssnick/tonutils-go/ton/jetton"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
tonMasterShard = int64(-0x8000000000000000)
|
||||||
|
tonScannerConnectTimeout = 20 * time.Second
|
||||||
|
tonScannerRequestTimeout = 15 * time.Second
|
||||||
|
tonScannerRetryDelay = 3 * time.Second
|
||||||
|
tonCatchupYield = 250 * time.Millisecond
|
||||||
|
tonMaxMasterBlocksTick = uint32(50)
|
||||||
|
tonBlockTxPageSize = uint32(256)
|
||||||
|
)
|
||||||
|
|
||||||
|
type tonWatchState struct {
|
||||||
|
wallets map[string]*address.Address
|
||||||
|
nativeToken *mdb.ChainToken
|
||||||
|
jettonTokens []mdb.ChainToken
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *tonWatchState) hasWalletInWorkchain(workchain int32) bool {
|
||||||
|
if s == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, wallet := range s.wallets {
|
||||||
|
if wallet != nil && wallet.Workchain() == workchain {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
type tonRuntimeCursor struct {
|
||||||
|
initialized bool
|
||||||
|
lastMasterSeqNo uint32
|
||||||
|
shardSeqNo map[string]uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tonRuntimeCursor) ensureShardSeqNo() {
|
||||||
|
if c.shardSeqNo == nil {
|
||||||
|
c.shardSeqNo = make(map[string]uint32)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type tonJettonWalletCache struct {
|
||||||
|
sync.Mutex
|
||||||
|
walletByOwnerToken map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTonJettonWalletCache() *tonJettonWalletCache {
|
||||||
|
return &tonJettonWalletCache{walletByOwnerToken: make(map[string]string)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartTonBlockScannerListener runs a forward-only TON masterchain scanner.
|
||||||
|
func StartTonBlockScannerListener() {
|
||||||
|
cache := newTonJettonWalletCache()
|
||||||
|
cursor := &tonRuntimeCursor{}
|
||||||
|
for {
|
||||||
|
chain, interval := tonChainConfig()
|
||||||
|
if chain == nil || !chain.Enabled {
|
||||||
|
sleepOrDone(context.Background(), interval)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
node, ok := resolveTonLiteNode()
|
||||||
|
if !ok {
|
||||||
|
sleepOrDone(context.Background(), interval)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Sugar.Infof("[TON] connecting using lite node %s", data.RpcNodeLogLabel(node))
|
||||||
|
api, closeFn, err := connectTonLiteAPI(node)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TON] connect lite node %s failed: %v", data.RpcNodeLogLabel(node), err)
|
||||||
|
recordTonLiteNodeFailure(node, "connect")
|
||||||
|
sleepOrDone(context.Background(), tonScannerRetryDelay)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Sugar.Infof("[TON] connected using lite node %s", data.RpcNodeLogLabel(node))
|
||||||
|
err = runTonScanner(context.Background(), api, cache, cursor)
|
||||||
|
closeFn()
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TON] scanner stopped: %v", err)
|
||||||
|
recordTonLiteNodeFailure(node, err.Error())
|
||||||
|
sleepOrDone(context.Background(), tonScannerRetryDelay)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTonScanner(ctx context.Context, api ton.APIClientWrapped, cache *tonJettonWalletCache, cursor *tonRuntimeCursor) error {
|
||||||
|
if cursor == nil {
|
||||||
|
cursor = &tonRuntimeCursor{}
|
||||||
|
}
|
||||||
|
watchSignature := ""
|
||||||
|
for {
|
||||||
|
chain, interval := tonChainConfig()
|
||||||
|
if chain == nil || !chain.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
state, err := loadTonWatchState()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(state.wallets) == 0 || (state.nativeToken == nil && len(state.jettonTokens) == 0) {
|
||||||
|
log.Sugar.Debug("[TON] no enabled wallets or tokens, idling")
|
||||||
|
if !sleepOrDone(ctx, interval) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sig := tonWatchStateSignature(state); sig != watchSignature {
|
||||||
|
watchSignature = sig
|
||||||
|
log.Sugar.Infof(
|
||||||
|
"[TON] watching wallets=%d native_ton=%t jetton_tokens=%d wallet_workchains=%s",
|
||||||
|
len(state.wallets),
|
||||||
|
state.nativeToken != nil,
|
||||||
|
len(state.jettonTokens),
|
||||||
|
tonWalletWorkchainSummary(state),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
head, err := api.CurrentMasterchainInfo(ctx)
|
||||||
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkTon)
|
||||||
|
return fmt.Errorf("current masterchain info: %w", err)
|
||||||
|
}
|
||||||
|
confirmedHeadSeqNo := tonConfirmedMasterSeqNo(head.SeqNo, chain.MinConfirmations)
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkTon)
|
||||||
|
data.RecordRpcBlockHeight(mdb.NetworkTon, int64(head.SeqNo))
|
||||||
|
|
||||||
|
if !cursor.initialized {
|
||||||
|
if err = initializeTonRuntimeCursor(ctx, api, head, confirmedHeadSeqNo, cursor); err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkTon)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Sugar.Infof(
|
||||||
|
"[TON] current masterchain head=%d confirmed=%d root=%s file=%s",
|
||||||
|
head.SeqNo,
|
||||||
|
confirmedHeadSeqNo,
|
||||||
|
shortTonHash(head.RootHash),
|
||||||
|
shortTonHash(head.FileHash),
|
||||||
|
)
|
||||||
|
log.Sugar.Infof("[TON] initialized runtime cursor at masterchain seqno=%d head=%d", cursor.lastMasterSeqNo, head.SeqNo)
|
||||||
|
if !sleepOrDone(ctx, interval) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cursor.lastMasterSeqNo >= confirmedHeadSeqNo {
|
||||||
|
if !sleepOrDone(ctx, interval) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
target := cursor.lastMasterSeqNo + tonMaxMasterBlocksTick
|
||||||
|
if target > confirmedHeadSeqNo {
|
||||||
|
target = confirmedHeadSeqNo
|
||||||
|
}
|
||||||
|
log.Sugar.Infof("[TON] processing masterchain blocks from=%d to=%d head=%d confirmed=%d", cursor.lastMasterSeqNo+1, target, head.SeqNo, confirmedHeadSeqNo)
|
||||||
|
for seq := cursor.lastMasterSeqNo + 1; seq <= target; seq++ {
|
||||||
|
master, err := api.WaitForBlock(seq).LookupBlock(ctx, address.MasterchainID, tonMasterShard, seq)
|
||||||
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkTon)
|
||||||
|
return fmt.Errorf("lookup masterchain seqno=%d: %w", seq, err)
|
||||||
|
}
|
||||||
|
if err = processTonMasterBlockWithCursor(ctx, api, master, state, cache, cursor); err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkTon)
|
||||||
|
return fmt.Errorf("process masterchain seqno=%d: %w", seq, err)
|
||||||
|
}
|
||||||
|
cursor.lastMasterSeqNo = master.SeqNo
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkTon)
|
||||||
|
data.RecordRpcBlockHeight(mdb.NetworkTon, int64(seq))
|
||||||
|
}
|
||||||
|
if target < confirmedHeadSeqNo {
|
||||||
|
if !sleepOrDone(ctx, tonCatchupYield) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonConfirmedMasterSeqNo(headSeqNo uint32, minConfirmations int) uint32 {
|
||||||
|
if minConfirmations <= 1 {
|
||||||
|
return headSeqNo
|
||||||
|
}
|
||||||
|
conf := uint32(minConfirmations)
|
||||||
|
if headSeqNo < conf-1 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return headSeqNo - conf + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeTonRuntimeCursor(ctx context.Context, api ton.APIClientWrapped, head *ton.BlockIDExt, confirmedHeadSeqNo uint32, cursor *tonRuntimeCursor) error {
|
||||||
|
if cursor == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if head == nil {
|
||||||
|
return fmt.Errorf("current masterchain head is nil")
|
||||||
|
}
|
||||||
|
initMaster := head
|
||||||
|
if confirmedHeadSeqNo > 0 && confirmedHeadSeqNo != head.SeqNo {
|
||||||
|
var err error
|
||||||
|
initMaster, err = api.WaitForBlock(confirmedHeadSeqNo).LookupBlock(ctx, address.MasterchainID, tonMasterShard, confirmedHeadSeqNo)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("lookup initial confirmed masterchain seqno=%d: %w", confirmedHeadSeqNo, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if confirmedHeadSeqNo > 0 {
|
||||||
|
if err := initializeTonShardCursors(ctx, api, initMaster, cursor); err != nil {
|
||||||
|
return fmt.Errorf("initialize shard cursors masterchain seqno=%d: %w", initMaster.SeqNo, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cursor.initialized = true
|
||||||
|
cursor.lastMasterSeqNo = confirmedHeadSeqNo
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processTonMasterBlock(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache) error {
|
||||||
|
return processTonMasterBlockWithCursor(ctx, api, master, state, cache, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func processTonMasterBlockWithCursor(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache, cursor *tonRuntimeCursor) error {
|
||||||
|
if state.hasWalletInWorkchain(address.MasterchainID) {
|
||||||
|
if err := processTonShardBlock(ctx, api, master, master, state, cache); err != nil {
|
||||||
|
return fmt.Errorf("process masterchain transactions: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shards, err := api.WaitForBlock(master.SeqNo).GetBlockShardsInfo(ctx, master)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Sugar.Debugf(
|
||||||
|
"[TON] masterchain seqno=%d root=%s file=%s shard_blocks=%d workchains=%s",
|
||||||
|
master.SeqNo,
|
||||||
|
shortTonHash(master.RootHash),
|
||||||
|
shortTonHash(master.FileHash),
|
||||||
|
len(shards),
|
||||||
|
tonShardWorkchainSummary(shards),
|
||||||
|
)
|
||||||
|
if cursor != nil {
|
||||||
|
return processTonShardBlocksWithCursor(ctx, api, master, shards, state, cache, cursor)
|
||||||
|
}
|
||||||
|
for _, shard := range shards {
|
||||||
|
if err = processTonShardBlock(ctx, api, master, shard, state, cache); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeTonShardCursors(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, cursor *tonRuntimeCursor) error {
|
||||||
|
if cursor == nil || master == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
shards, err := api.WaitForBlock(master.SeqNo).GetBlockShardsInfo(ctx, master)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cursor.ensureShardSeqNo()
|
||||||
|
for _, shard := range shards {
|
||||||
|
if shard == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cursor.shardSeqNo[tonShardCursorKey(shard)] = shard.SeqNo
|
||||||
|
}
|
||||||
|
log.Sugar.Infof(
|
||||||
|
"[TON] initialized shard cursors masterchain seqno=%d shard_blocks=%d workchains=%s",
|
||||||
|
master.SeqNo,
|
||||||
|
len(shards),
|
||||||
|
tonShardWorkchainSummary(shards),
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processTonShardBlocksWithCursor(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, shards []*ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache, cursor *tonRuntimeCursor) error {
|
||||||
|
if cursor == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cursor.ensureShardSeqNo()
|
||||||
|
for _, shard := range shards {
|
||||||
|
if shard == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := tonShardCursorKey(shard)
|
||||||
|
lastSeqNo, ok := cursor.shardSeqNo[key]
|
||||||
|
if !state.hasWalletInWorkchain(shard.Workchain) {
|
||||||
|
cursor.shardSeqNo[key] = shard.SeqNo
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
start := shard.SeqNo
|
||||||
|
if ok {
|
||||||
|
if shard.SeqNo <= lastSeqNo {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start = lastSeqNo + 1
|
||||||
|
}
|
||||||
|
if shard.SeqNo > start {
|
||||||
|
log.Sugar.Infof(
|
||||||
|
"[TON] processing shard blocks master_seqno=%d workchain=%d shard=%x from=%d to=%d",
|
||||||
|
master.SeqNo,
|
||||||
|
shard.Workchain,
|
||||||
|
uint64(shard.Shard),
|
||||||
|
start,
|
||||||
|
shard.SeqNo,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for seq := start; seq <= shard.SeqNo; seq++ {
|
||||||
|
shardBlock := shard
|
||||||
|
if seq != shard.SeqNo {
|
||||||
|
var err error
|
||||||
|
shardBlock, err = api.WaitForBlock(master.SeqNo).LookupBlock(ctx, shard.Workchain, shard.Shard, seq)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("lookup shard block workchain=%d shard=%x seqno=%d: %w", shard.Workchain, uint64(shard.Shard), seq, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := processTonShardBlock(ctx, api, master, shardBlock, state, cache); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cursor.shardSeqNo[key] = seq
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processTonShardBlock(ctx context.Context, api ton.APIClientWrapped, master, shard *ton.BlockIDExt, state *tonWatchState, cache *tonJettonWalletCache) error {
|
||||||
|
log.Sugar.Debugf("[TON] scanning shard master_seqno=%d workchain=%d shard=%d shard_seqno=%d", master.SeqNo, shard.Workchain, shard.Shard, shard.SeqNo)
|
||||||
|
var after *ton.TransactionID3
|
||||||
|
for {
|
||||||
|
shorts, incomplete, err := api.WaitForBlock(master.SeqNo).GetBlockTransactionsV2(ctx, shard, tonBlockTxPageSize, after)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range shorts {
|
||||||
|
account := addressutil.TonAddressFromShortAccount(shard.Workchain, shorts[i].Account)
|
||||||
|
if account == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
receive, ok := state.wallets[addressutil.TonRawAddressObjectKey(account)]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Sugar.Debugf("[TON] watched account tx candidate master_seqno=%d workchain=%d shard=%d lt=%d account=%s", master.SeqNo, shard.Workchain, shard.Shard, shorts[i].LT, addressutil.TonRawAddressObjectKey(account))
|
||||||
|
tx, err := api.WaitForBlock(master.SeqNo).GetTransaction(ctx, shard, account, shorts[i].LT)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if tx == nil {
|
||||||
|
return fmt.Errorf("missing full transaction workchain=%d shard=%d seqno=%d lt=%d", shard.Workchain, shard.Shard, shard.SeqNo, shorts[i].LT)
|
||||||
|
}
|
||||||
|
jettonWallets, err := cache.jettonWalletTokens(ctx, api, master, receive, state.jettonTokens)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
transfer, err := service.ParseTonInboundTransfer(tx, receive, state.nativeToken, jettonWallets)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TON] parse inbound tx lt=%d hash=%s: %v", tx.LT, hex.EncodeToString(tx.Hash), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if transfer == nil {
|
||||||
|
log.Sugar.Debugf(
|
||||||
|
"[TON] unsupported watched tx master_seqno=%d workchain=%d shard=%d lt=%d hash=%s account=%s",
|
||||||
|
master.SeqNo,
|
||||||
|
shard.Workchain,
|
||||||
|
shard.Shard,
|
||||||
|
tx.LT,
|
||||||
|
hex.EncodeToString(tx.Hash),
|
||||||
|
addressutil.TonRawAddressObjectKey(account),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
service.TryProcessTonTransfer(transfer)
|
||||||
|
}
|
||||||
|
if !incomplete {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(shorts) == 0 {
|
||||||
|
return fmt.Errorf("incomplete transaction page without cursor progress workchain=%d shard=%d seqno=%d", shard.Workchain, shard.Shard, shard.SeqNo)
|
||||||
|
}
|
||||||
|
after = shorts[len(shorts)-1].ID3()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *tonJettonWalletCache) jettonWalletTokens(ctx context.Context, api ton.APIClientWrapped, master *ton.BlockIDExt, owner *address.Address, tokens []mdb.ChainToken) (map[string]mdb.ChainToken, error) {
|
||||||
|
out := make(map[string]mdb.ChainToken, len(tokens))
|
||||||
|
ownerRaw := addressutil.TonRawAddressObjectKey(owner)
|
||||||
|
for _, token := range tokens {
|
||||||
|
masterParsed, err := addressutil.ParseTonMainnetAddress(token.ContractAddress)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid jetton master symbol=%s address=%s: %w", token.Symbol, token.ContractAddress, err)
|
||||||
|
}
|
||||||
|
cacheKey := masterParsed.Raw + "|" + ownerRaw
|
||||||
|
c.Lock()
|
||||||
|
walletRaw, ok := c.walletByOwnerToken[cacheKey]
|
||||||
|
c.Unlock()
|
||||||
|
if !ok {
|
||||||
|
wallet, err := jetton.NewJettonMasterClient(api, masterParsed.Address).GetJettonWalletAtBlock(ctx, owner, master)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("derive jetton wallet symbol=%s owner=%s: %w", token.Symbol, ownerRaw, err)
|
||||||
|
}
|
||||||
|
walletRaw = addressutil.TonRawAddressObjectKey(wallet.Address())
|
||||||
|
c.Lock()
|
||||||
|
c.walletByOwnerToken[cacheKey] = walletRaw
|
||||||
|
c.Unlock()
|
||||||
|
}
|
||||||
|
out[walletRaw] = token
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadTonWatchState() (*tonWatchState, error) {
|
||||||
|
walletRows, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTon)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTon)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
state := &tonWatchState{wallets: make(map[string]*address.Address)}
|
||||||
|
for _, row := range walletRows {
|
||||||
|
parsed, err := addressutil.ParseTonMainnetAddress(row.Address)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[TON] skip invalid wallet address=%s err=%v", row.Address, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
state.wallets[parsed.Raw] = parsed.Address
|
||||||
|
}
|
||||||
|
for i := range tokens {
|
||||||
|
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
|
||||||
|
contract := strings.TrimSpace(tokens[i].ContractAddress)
|
||||||
|
switch {
|
||||||
|
case sym == service.TonNativeSymbol && contract == "":
|
||||||
|
token := tokens[i]
|
||||||
|
state.nativeToken = &token
|
||||||
|
case contract != "":
|
||||||
|
state.jettonTokens = append(state.jettonTokens, tokens[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonChainConfig() (*mdb.Chain, time.Duration) {
|
||||||
|
row, err := data.GetChainByNetwork(mdb.NetworkTon)
|
||||||
|
if err != nil || row.ID == 0 {
|
||||||
|
return nil, 5 * time.Second
|
||||||
|
}
|
||||||
|
interval := time.Duration(row.ScanIntervalSec) * time.Second
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 5 * time.Second
|
||||||
|
}
|
||||||
|
return row, interval
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonWatchStateSignature(state *tonWatchState) string {
|
||||||
|
if state == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d|%t|%d|%s", len(state.wallets), state.nativeToken != nil, len(state.jettonTokens), tonWalletWorkchainSummary(state))
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonWalletWorkchainSummary(state *tonWatchState) string {
|
||||||
|
if state == nil || len(state.wallets) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
counts := make(map[int32]int)
|
||||||
|
for _, wallet := range state.wallets {
|
||||||
|
if wallet == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
counts[wallet.Workchain()]++
|
||||||
|
}
|
||||||
|
return formatTonWorkchainCounts(counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonShardWorkchainSummary(shards []*ton.BlockIDExt) string {
|
||||||
|
if len(shards) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
counts := make(map[int32]int)
|
||||||
|
for _, shard := range shards {
|
||||||
|
if shard == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
counts[shard.Workchain]++
|
||||||
|
}
|
||||||
|
return formatTonWorkchainCounts(counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tonShardCursorKey(shard *ton.BlockIDExt) string {
|
||||||
|
if shard == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d:%d", shard.Workchain, shard.Shard)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTonWorkchainCounts(counts map[int32]int) string {
|
||||||
|
if len(counts) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
workchains := make([]int, 0, len(counts))
|
||||||
|
for workchain := range counts {
|
||||||
|
workchains = append(workchains, int(workchain))
|
||||||
|
}
|
||||||
|
sort.Ints(workchains)
|
||||||
|
parts := make([]string, 0, len(workchains))
|
||||||
|
for _, workchain := range workchains {
|
||||||
|
parts = append(parts, fmt.Sprintf("%d:%d", workchain, counts[int32(workchain)]))
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortTonHash(raw []byte) string {
|
||||||
|
hash := hex.EncodeToString(raw)
|
||||||
|
if len(hash) > 16 {
|
||||||
|
return hash[:16]
|
||||||
|
}
|
||||||
|
if hash == "" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveTonLiteNode(excludeIDs ...uint64) (mdb.RpcNode, bool) {
|
||||||
|
node, err := data.SelectGeneralRpcNode(mdb.NetworkTon, mdb.RpcNodeTypeLite, excludeIDs...)
|
||||||
|
if err == nil && node != nil && node.ID > 0 {
|
||||||
|
node.Url = strings.TrimSpace(node.Url)
|
||||||
|
if node.Url != "" {
|
||||||
|
return *node, true
|
||||||
|
}
|
||||||
|
log.Sugar.Errorf("[TON] rpc_nodes id=%d has empty url", node.ID)
|
||||||
|
return mdb.RpcNode{}, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Errorf("[TON] resolve lite rpc_nodes err=%v", err)
|
||||||
|
} else {
|
||||||
|
log.Sugar.Warn("[TON] no enabled lite RPC node configured in rpc_nodes")
|
||||||
|
}
|
||||||
|
return mdb.RpcNode{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func connectTonLiteAPI(node mdb.RpcNode) (ton.APIClientWrapped, func(), error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), tonScannerConnectTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return service.ConnectTonLiteAPI(ctx, node.Url, tonScannerRequestTimeout, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordTonLiteNodeFailure(node mdb.RpcNode, reason string) {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkTon)
|
||||||
|
failures, cooling := data.RecordRpcNodeFailure(node.ID)
|
||||||
|
nodeLabel := data.RpcNodeLogLabel(node)
|
||||||
|
if cooling {
|
||||||
|
log.Sugar.Warnf("[TON] lite node reached fail threshold (%s), node=%s failures=%d/%d", reason, nodeLabel, failures, data.RpcFailoverThreshold)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Sugar.Warnf("[TON] lite node failed (%s), node=%s failures=%d/%d", reason, nodeLabel, failures, data.RpcFailoverThreshold)
|
||||||
|
}
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
|
"github.com/xssnick/tonutils-go/tlb"
|
||||||
|
"github.com/xssnick/tonutils-go/ton"
|
||||||
|
"github.com/xssnick/tonutils-go/ton/jetton"
|
||||||
|
"github.com/xssnick/tonutils-go/tvm/cell"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeTonShardAPI struct {
|
||||||
|
ton.APIClientWrapped
|
||||||
|
shorts []ton.TransactionShortInfo
|
||||||
|
shortsByShardSeq map[uint32][]ton.TransactionShortInfo
|
||||||
|
incomplete bool
|
||||||
|
shards []*ton.BlockIDExt
|
||||||
|
shardsErr error
|
||||||
|
lookupBlocks map[uint32]*ton.BlockIDExt
|
||||||
|
lookupSeqs []uint32
|
||||||
|
txByLT map[uint64]*tlb.Transaction
|
||||||
|
fetchedLTs []uint64
|
||||||
|
fetchedAddrs []string
|
||||||
|
fetchedShardSeqNos []uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeTonScannerAPI struct {
|
||||||
|
ton.APIClientWrapped
|
||||||
|
head *ton.BlockIDExt
|
||||||
|
shards []*ton.BlockIDExt
|
||||||
|
shardsErr error
|
||||||
|
lookups []uint32
|
||||||
|
cancel context.CancelFunc
|
||||||
|
cancelAtLookupSeq uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTonScannerAPI) CurrentMasterchainInfo(context.Context) (*ton.BlockIDExt, error) {
|
||||||
|
return f.head, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTonScannerAPI) WaitForBlock(uint32) ton.APIClientWrapped {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTonScannerAPI) LookupBlock(_ context.Context, workchain int32, shard int64, seqno uint32) (*ton.BlockIDExt, error) {
|
||||||
|
f.lookups = append(f.lookups, seqno)
|
||||||
|
if f.cancel != nil && f.cancelAtLookupSeq == seqno {
|
||||||
|
f.cancel()
|
||||||
|
}
|
||||||
|
return &ton.BlockIDExt{
|
||||||
|
Workchain: workchain,
|
||||||
|
Shard: shard,
|
||||||
|
SeqNo: seqno,
|
||||||
|
RootHash: bytes.Repeat([]byte{byte(seqno)}, 32),
|
||||||
|
FileHash: bytes.Repeat([]byte{byte(seqno + 1)}, 32),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTonScannerAPI) GetBlockShardsInfo(context.Context, *ton.BlockIDExt) ([]*ton.BlockIDExt, error) {
|
||||||
|
if f.shardsErr != nil {
|
||||||
|
return nil, f.shardsErr
|
||||||
|
}
|
||||||
|
return f.shards, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTonShardAPI) WaitForBlock(uint32) ton.APIClientWrapped {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTonShardAPI) GetBlockTransactionsV2(_ context.Context, block *ton.BlockIDExt, _ uint32, _ ...*ton.TransactionID3) ([]ton.TransactionShortInfo, bool, error) {
|
||||||
|
if block != nil {
|
||||||
|
f.fetchedShardSeqNos = append(f.fetchedShardSeqNos, block.SeqNo)
|
||||||
|
}
|
||||||
|
return f.blockShorts(block), f.incomplete, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTonShardAPI) blockShorts(block *ton.BlockIDExt) []ton.TransactionShortInfo {
|
||||||
|
if block != nil && f.shortsByShardSeq != nil {
|
||||||
|
if shorts, ok := f.shortsByShardSeq[block.SeqNo]; ok {
|
||||||
|
return shorts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return f.shorts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTonShardAPI) GetBlockShardsInfo(context.Context, *ton.BlockIDExt) ([]*ton.BlockIDExt, error) {
|
||||||
|
if f.shardsErr != nil {
|
||||||
|
return nil, f.shardsErr
|
||||||
|
}
|
||||||
|
return f.shards, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTonShardAPI) LookupBlock(_ context.Context, workchain int32, shard int64, seqno uint32) (*ton.BlockIDExt, error) {
|
||||||
|
f.lookupSeqs = append(f.lookupSeqs, seqno)
|
||||||
|
if f.lookupBlocks != nil {
|
||||||
|
if block, ok := f.lookupBlocks[seqno]; ok {
|
||||||
|
return block, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ton.BlockIDExt{Workchain: workchain, Shard: shard, SeqNo: seqno}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeTonShardAPI) GetTransaction(_ context.Context, _ *ton.BlockIDExt, addr *address.Address, lt uint64) (*tlb.Transaction, error) {
|
||||||
|
f.fetchedLTs = append(f.fetchedLTs, lt)
|
||||||
|
f.fetchedAddrs = append(f.fetchedAddrs, addressutil.TonRawAddressObjectKey(addr))
|
||||||
|
tx := f.txByLT[lt]
|
||||||
|
if tx == nil {
|
||||||
|
return nil, fmt.Errorf("missing tx lt=%d", lt)
|
||||||
|
}
|
||||||
|
return tx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedTonScannerWallet(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"); err != nil {
|
||||||
|
t.Fatalf("seed TON wallet: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveTonLiteNodeSkipsCoolingPrimary(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
data.ResetRpcFailoverForTest()
|
||||||
|
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||||
|
|
||||||
|
rows := []mdb.RpcNode{
|
||||||
|
{Network: mdb.NetworkTon, Url: " https://primary-ton.example.com/global.config.json ", Type: mdb.RpcNodeTypeLite, Weight: 100, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkTon, Url: "https://backup-ton.example.com/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkTon, Url: "https://manual-ton.example.com/global.config.json", Type: mdb.RpcNodeTypeLite, Weight: 1000, Enabled: true, Purpose: mdb.RpcNodePurposeManualVerify, Status: mdb.RpcNodeStatusOk},
|
||||||
|
{Network: mdb.NetworkTon, Url: "https://http-ton.example.com", Type: mdb.RpcNodeTypeHttp, Weight: 2000, Enabled: true, Purpose: mdb.RpcNodePurposeGeneral, Status: mdb.RpcNodeStatusOk},
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatalf("seed rpc_nodes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
primary, ok := resolveTonLiteNode()
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("resolveTonLiteNode() ok=false, want true")
|
||||||
|
}
|
||||||
|
if primary.Url != "https://primary-ton.example.com/global.config.json" {
|
||||||
|
t.Fatalf("primary node = %#v, want trimmed primary lite node", primary)
|
||||||
|
}
|
||||||
|
for i := 0; i < data.RpcFailoverThreshold; i++ {
|
||||||
|
data.RecordRpcNodeFailure(primary.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, ok := resolveTonLiteNode()
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("resolveTonLiteNode() after cooldown ok=false, want true")
|
||||||
|
}
|
||||||
|
if got.Url != "https://backup-ton.example.com/global.config.json" {
|
||||||
|
t.Fatalf("resolveTonLiteNode() after cooldown = %#v, want backup lite node", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTonScannerInitializesCursorAtCurrentHead(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
seedTonScannerWallet(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
api := &fakeTonScannerAPI{head: &ton.BlockIDExt{
|
||||||
|
Workchain: address.MasterchainID,
|
||||||
|
Shard: tonMasterShard,
|
||||||
|
SeqNo: 77,
|
||||||
|
RootHash: bytes.Repeat([]byte{0x77}, 32),
|
||||||
|
FileHash: bytes.Repeat([]byte{0x78}, 32),
|
||||||
|
}}
|
||||||
|
cursor := &tonRuntimeCursor{}
|
||||||
|
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
||||||
|
t.Fatalf("runTonScanner(): %v", err)
|
||||||
|
}
|
||||||
|
if !cursor.initialized {
|
||||||
|
t.Fatal("cursor not initialized")
|
||||||
|
}
|
||||||
|
if cursor.lastMasterSeqNo != 77 {
|
||||||
|
t.Fatalf("cursor seqno = %d, want 77", cursor.lastMasterSeqNo)
|
||||||
|
}
|
||||||
|
if len(api.lookups) != 0 {
|
||||||
|
t.Fatalf("initial cursor should not backfill, lookups=%#v", api.lookups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTonScannerIdleStatesPreserveInitializedCursor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setup func(t *testing.T)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "chain disabled",
|
||||||
|
setup: func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
seedTonScannerWallet(t)
|
||||||
|
if err := data.UpdateChainFields(mdb.NetworkTon, map[string]interface{}{"enabled": false}); err != nil {
|
||||||
|
t.Fatalf("disable TON chain: %v", err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no enabled wallets",
|
||||||
|
setup: func(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
tt.setup(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
api := &fakeTonScannerAPI{
|
||||||
|
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11},
|
||||||
|
}
|
||||||
|
cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 10}
|
||||||
|
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
||||||
|
t.Fatalf("runTonScanner(): %v", err)
|
||||||
|
}
|
||||||
|
if !cursor.initialized || cursor.lastMasterSeqNo != 10 {
|
||||||
|
t.Fatalf("cursor changed during idle state: initialized=%v seqno=%d, want initialized=true seqno=10", cursor.initialized, cursor.lastMasterSeqNo)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTonScannerDoesNotAdvanceCursorOnMasterProcessingFailure(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
seedTonScannerWallet(t)
|
||||||
|
|
||||||
|
api := &fakeTonScannerAPI{
|
||||||
|
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11},
|
||||||
|
shardsErr: errors.New("shard fetch failed"),
|
||||||
|
}
|
||||||
|
cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 10}
|
||||||
|
err := runTonScanner(context.Background(), api, newTonJettonWalletCache(), cursor)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected scanner failure")
|
||||||
|
}
|
||||||
|
if cursor.lastMasterSeqNo != 10 {
|
||||||
|
t.Fatalf("cursor advanced after failed block: %d, want 10", cursor.lastMasterSeqNo)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
api = &fakeTonScannerAPI{
|
||||||
|
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11},
|
||||||
|
shards: nil,
|
||||||
|
}
|
||||||
|
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
||||||
|
t.Fatalf("runTonScanner retry: %v", err)
|
||||||
|
}
|
||||||
|
if len(api.lookups) != 1 || api.lookups[0] != 11 {
|
||||||
|
t.Fatalf("retry lookups = %#v, want [11]", api.lookups)
|
||||||
|
}
|
||||||
|
if cursor.lastMasterSeqNo != 11 {
|
||||||
|
t.Fatalf("cursor seqno after retry = %d, want 11", cursor.lastMasterSeqNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTonScannerDoesNotMarkCursorInitializedOnInitialShardFailure(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
seedTonScannerWallet(t)
|
||||||
|
|
||||||
|
api := &fakeTonScannerAPI{
|
||||||
|
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 11},
|
||||||
|
shardsErr: errors.New("initial shard fetch failed"),
|
||||||
|
}
|
||||||
|
cursor := &tonRuntimeCursor{}
|
||||||
|
err := runTonScanner(context.Background(), api, newTonJettonWalletCache(), cursor)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected scanner failure")
|
||||||
|
}
|
||||||
|
if cursor.initialized {
|
||||||
|
t.Fatal("cursor was marked initialized after failed initial shard cursor setup")
|
||||||
|
}
|
||||||
|
if cursor.lastMasterSeqNo != 0 {
|
||||||
|
t.Fatalf("cursor master seqno = %d, want 0", cursor.lastMasterSeqNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTonScannerRespectsMinConfirmations(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
seedTonScannerWallet(t)
|
||||||
|
if err := data.UpdateChainFields(mdb.NetworkTon, map[string]interface{}{"min_confirmations": 3}); err != nil {
|
||||||
|
t.Fatalf("set TON min confirmations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
api := &fakeTonScannerAPI{
|
||||||
|
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 12},
|
||||||
|
}
|
||||||
|
cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 10}
|
||||||
|
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
||||||
|
t.Fatalf("runTonScanner(): %v", err)
|
||||||
|
}
|
||||||
|
if len(api.lookups) != 0 {
|
||||||
|
t.Fatalf("scanner processed unconfirmed blocks, lookups=%#v", api.lookups)
|
||||||
|
}
|
||||||
|
if cursor.lastMasterSeqNo != 10 {
|
||||||
|
t.Fatalf("cursor seqno = %d, want 10", cursor.lastMasterSeqNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTonScannerCatchupProcessesAtMostFiftyMasterBlocksPerTick(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
seedTonScannerWallet(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
api := &fakeTonScannerAPI{
|
||||||
|
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 100},
|
||||||
|
shards: nil,
|
||||||
|
}
|
||||||
|
cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 1}
|
||||||
|
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
||||||
|
t.Fatalf("runTonScanner(): %v", err)
|
||||||
|
}
|
||||||
|
if len(api.lookups) != int(tonMaxMasterBlocksTick) {
|
||||||
|
t.Fatalf("lookup count = %d, want %d", len(api.lookups), tonMaxMasterBlocksTick)
|
||||||
|
}
|
||||||
|
if cursor.lastMasterSeqNo != 51 {
|
||||||
|
t.Fatalf("cursor seqno = %d, want 51", cursor.lastMasterSeqNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunTonScannerCatchupContinuesUntilConfirmedHead(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
seedTonScannerWallet(t)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
api := &fakeTonScannerAPI{
|
||||||
|
head: &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 60},
|
||||||
|
shards: nil,
|
||||||
|
cancel: cancel,
|
||||||
|
cancelAtLookupSeq: 60,
|
||||||
|
}
|
||||||
|
cursor := &tonRuntimeCursor{initialized: true, lastMasterSeqNo: 1}
|
||||||
|
if err := runTonScanner(ctx, api, newTonJettonWalletCache(), cursor); err != nil {
|
||||||
|
t.Fatalf("runTonScanner(): %v", err)
|
||||||
|
}
|
||||||
|
if len(api.lookups) != 59 {
|
||||||
|
t.Fatalf("lookup count = %d, want 59", len(api.lookups))
|
||||||
|
}
|
||||||
|
if api.lookups[0] != 2 || api.lookups[len(api.lookups)-1] != 60 {
|
||||||
|
t.Fatalf("lookups = %#v, want continuous 2..60", api.lookups)
|
||||||
|
}
|
||||||
|
for i, seq := range api.lookups {
|
||||||
|
want := uint32(i + 2)
|
||||||
|
if seq != want {
|
||||||
|
t.Fatalf("lookup[%d] = %d, want %d", i, seq, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cursor.lastMasterSeqNo != 60 {
|
||||||
|
t.Fatalf("cursor seqno = %d, want 60", cursor.lastMasterSeqNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTonShardBlockFetchesOnlyWatchedWalletTransactions(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
unwatched := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x77}, 32)).Bounce(false).Testnet(false)
|
||||||
|
sender := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false)
|
||||||
|
|
||||||
|
tx := &tlb.Transaction{
|
||||||
|
LT: 2,
|
||||||
|
Now: uint32(time.Now().Unix()),
|
||||||
|
Hash: bytes.Repeat([]byte{0x99}, 32),
|
||||||
|
IO: struct {
|
||||||
|
In *tlb.Message `tlb:"maybe ^"`
|
||||||
|
Out *tlb.MessagesList `tlb:"maybe ^"`
|
||||||
|
}{
|
||||||
|
In: &tlb.Message{
|
||||||
|
MsgType: tlb.MsgTypeInternal,
|
||||||
|
Msg: &tlb.InternalMessage{
|
||||||
|
SrcAddr: sender,
|
||||||
|
DstAddr: receive,
|
||||||
|
Amount: tlb.MustFromTON("1"),
|
||||||
|
Body: cell.BeginCell().EndCell(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
api := &fakeTonShardAPI{
|
||||||
|
shorts: []ton.TransactionShortInfo{
|
||||||
|
{Account: unwatched.Data(), LT: 1, Hash: bytes.Repeat([]byte{0x01}, 32)},
|
||||||
|
{Account: receive.Data(), LT: 2, Hash: bytes.Repeat([]byte{0x02}, 32)},
|
||||||
|
},
|
||||||
|
txByLT: map[uint64]*tlb.Transaction{2: tx},
|
||||||
|
}
|
||||||
|
state := &tonWatchState{
|
||||||
|
wallets: map[string]*address.Address{
|
||||||
|
addressutil.TonRawAddressObjectKey(receive): receive,
|
||||||
|
},
|
||||||
|
nativeToken: &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: service.TonNativeSymbol, Decimals: 9, Enabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
||||||
|
shard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: 0, SeqNo: 9}
|
||||||
|
err := processTonShardBlock(context.Background(), api, master, shard, state, newTonJettonWalletCache())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processTonShardBlock(): %v", err)
|
||||||
|
}
|
||||||
|
if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != 2 {
|
||||||
|
t.Fatalf("fetched LTs = %#v, want [2]", api.fetchedLTs)
|
||||||
|
}
|
||||||
|
if len(api.fetchedAddrs) != 1 || api.fetchedAddrs[0] != addressutil.TonRawAddressObjectKey(receive) {
|
||||||
|
t.Fatalf("fetched addresses = %#v, want watched receive address", api.fetchedAddrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTonMasterBlockWithCursorScansSkippedShardBlocks(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false)
|
||||||
|
txHash := bytes.Repeat([]byte{0x42}, 32)
|
||||||
|
tx := &tlb.Transaction{
|
||||||
|
LT: 88,
|
||||||
|
Now: uint32(time.Now().Unix()),
|
||||||
|
Hash: txHash,
|
||||||
|
IO: struct {
|
||||||
|
In *tlb.Message `tlb:"maybe ^"`
|
||||||
|
Out *tlb.MessagesList `tlb:"maybe ^"`
|
||||||
|
}{
|
||||||
|
In: &tlb.Message{
|
||||||
|
MsgType: tlb.MsgTypeInternal,
|
||||||
|
Msg: &tlb.InternalMessage{
|
||||||
|
SrcAddr: sender,
|
||||||
|
DstAddr: receive,
|
||||||
|
Amount: tlb.MustFromTON("1"),
|
||||||
|
Body: cell.BeginCell().EndCell(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
currentShard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: tonMasterShard, SeqNo: 89}
|
||||||
|
skippedShard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: tonMasterShard, SeqNo: 88}
|
||||||
|
api := &fakeTonShardAPI{
|
||||||
|
shards: []*ton.BlockIDExt{currentShard},
|
||||||
|
lookupBlocks: map[uint32]*ton.BlockIDExt{88: skippedShard},
|
||||||
|
shortsByShardSeq: map[uint32][]ton.TransactionShortInfo{
|
||||||
|
88: {{Account: receive.Data(), LT: tx.LT, Hash: txHash}},
|
||||||
|
89: nil,
|
||||||
|
},
|
||||||
|
txByLT: map[uint64]*tlb.Transaction{tx.LT: tx},
|
||||||
|
}
|
||||||
|
state := &tonWatchState{
|
||||||
|
wallets: map[string]*address.Address{
|
||||||
|
addressutil.TonRawAddressObjectKey(receive): receive,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cursorKey := tonShardCursorKey(currentShard)
|
||||||
|
cursor := &tonRuntimeCursor{
|
||||||
|
initialized: true,
|
||||||
|
lastMasterSeqNo: 10,
|
||||||
|
shardSeqNo: map[string]uint32{cursorKey: 87},
|
||||||
|
}
|
||||||
|
|
||||||
|
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
||||||
|
err := processTonMasterBlockWithCursor(context.Background(), api, master, state, newTonJettonWalletCache(), cursor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("processTonMasterBlockWithCursor(): %v", err)
|
||||||
|
}
|
||||||
|
if len(api.lookupSeqs) != 1 || api.lookupSeqs[0] != 88 {
|
||||||
|
t.Fatalf("lookup shard seqs = %#v, want [88]", api.lookupSeqs)
|
||||||
|
}
|
||||||
|
if len(api.fetchedShardSeqNos) != 2 || api.fetchedShardSeqNos[0] != 88 || api.fetchedShardSeqNos[1] != 89 {
|
||||||
|
t.Fatalf("fetched shard seqs = %#v, want [88 89]", api.fetchedShardSeqNos)
|
||||||
|
}
|
||||||
|
if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != tx.LT {
|
||||||
|
t.Fatalf("fetched LTs = %#v, want [%d]", api.fetchedLTs, tx.LT)
|
||||||
|
}
|
||||||
|
if cursor.shardSeqNo[cursorKey] != 89 {
|
||||||
|
t.Fatalf("shard cursor = %d, want 89", cursor.shardSeqNo[cursorKey])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTonShardBlockMatchesJettonUSDTOrder(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := data.SetSetting("rate", "rate.forced_rate_list", `{"cny":{"usdt":0.1}}`, "json"); err != nil {
|
||||||
|
t.Fatalf("set forced rate: %v", err)
|
||||||
|
}
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkTon, receive.StringRaw()); err != nil {
|
||||||
|
t.Fatalf("add ton wallet: %v", err)
|
||||||
|
}
|
||||||
|
resp, err := service.CreateTransaction(&request.CreateTransactionRequest{
|
||||||
|
OrderId: "order_ton_usdt_shard_1",
|
||||||
|
Currency: "CNY",
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTon,
|
||||||
|
Amount: 10,
|
||||||
|
NotifyUrl: "https://93.184.216.34/callback",
|
||||||
|
}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create TON USDT order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sender := address.NewAddress(0, 0, bytes.Repeat([]byte{0x31}, 32)).Bounce(false).Testnet(false)
|
||||||
|
jettonWallet := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x44}, 32)).Bounce(false).Testnet(false)
|
||||||
|
body, err := tlb.ToCell(jetton.TransferNotification{
|
||||||
|
QueryID: 7,
|
||||||
|
Amount: tlb.MustFromNano(big.NewInt(1_000_000), 6),
|
||||||
|
Sender: sender,
|
||||||
|
ForwardPayload: cell.BeginCell().EndCell(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build jetton notification body: %v", err)
|
||||||
|
}
|
||||||
|
tx := &tlb.Transaction{
|
||||||
|
LT: 22,
|
||||||
|
Now: uint32(time.Now().Add(time.Second).Unix()),
|
||||||
|
Hash: bytes.Repeat([]byte{0x42}, 32),
|
||||||
|
IO: struct {
|
||||||
|
In *tlb.Message `tlb:"maybe ^"`
|
||||||
|
Out *tlb.MessagesList `tlb:"maybe ^"`
|
||||||
|
}{
|
||||||
|
In: &tlb.Message{
|
||||||
|
MsgType: tlb.MsgTypeInternal,
|
||||||
|
Msg: &tlb.InternalMessage{
|
||||||
|
SrcAddr: jettonWallet,
|
||||||
|
DstAddr: receive,
|
||||||
|
Amount: tlb.FromNanoTONU(1),
|
||||||
|
Body: body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
usdtMaster, err := addressutil.ParseTonMainnetAddress("0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse USDT jetton master: %v", err)
|
||||||
|
}
|
||||||
|
cache := newTonJettonWalletCache()
|
||||||
|
ownerRaw := addressutil.TonRawAddressObjectKey(receive)
|
||||||
|
cache.walletByOwnerToken[usdtMaster.Raw+"|"+ownerRaw] = addressutil.TonRawAddressObjectKey(jettonWallet)
|
||||||
|
|
||||||
|
api := &fakeTonShardAPI{
|
||||||
|
shorts: []ton.TransactionShortInfo{
|
||||||
|
{Account: receive.Data(), LT: tx.LT, Hash: tx.Hash},
|
||||||
|
},
|
||||||
|
txByLT: map[uint64]*tlb.Transaction{tx.LT: tx},
|
||||||
|
}
|
||||||
|
state := &tonWatchState{
|
||||||
|
wallets: map[string]*address.Address{
|
||||||
|
ownerRaw: receive,
|
||||||
|
},
|
||||||
|
jettonTokens: []mdb.ChainToken{
|
||||||
|
{BaseModel: mdb.BaseModel{ID: 2}, Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: usdtMaster.Raw, Decimals: 6, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
||||||
|
shard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: 0, SeqNo: 9}
|
||||||
|
if err := processTonShardBlock(context.Background(), api, master, shard, state, cache); err != nil {
|
||||||
|
t.Fatalf("processTonShardBlock(): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload order: %v", err)
|
||||||
|
}
|
||||||
|
if order.Status != mdb.StatusPaySuccess {
|
||||||
|
t.Fatalf("order status = %d, want %d", order.Status, mdb.StatusPaySuccess)
|
||||||
|
}
|
||||||
|
wantBlockID := service.TonCanonicalBlockTransactionID(receive.StringRaw(), tx.LT, hex.EncodeToString(tx.Hash))
|
||||||
|
if order.BlockTransactionId != wantBlockID {
|
||||||
|
t.Fatalf("block transaction id = %q, want %q", order.BlockTransactionId, wantBlockID)
|
||||||
|
}
|
||||||
|
lock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTon, receive.StringRaw(), "USDT", resp.ActualAmount)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lookup TON USDT lock: %v", err)
|
||||||
|
}
|
||||||
|
if lock != "" {
|
||||||
|
t.Fatalf("TON USDT lock still exists after shard processing: %s", lock)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTonMasterBlockScansMasterchainWalletTransactions(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
masterchainID := address.MasterchainID
|
||||||
|
masterchainByte := byte(int8(masterchainID))
|
||||||
|
receive := address.NewAddress(0, masterchainByte, bytes.Repeat([]byte{0x66}, 32)).Bounce(false).Testnet(false)
|
||||||
|
sender := address.NewAddress(0, masterchainByte, bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false)
|
||||||
|
tx := &tlb.Transaction{
|
||||||
|
LT: 2,
|
||||||
|
Now: uint32(time.Now().Unix()),
|
||||||
|
Hash: bytes.Repeat([]byte{0x99}, 32),
|
||||||
|
IO: struct {
|
||||||
|
In *tlb.Message `tlb:"maybe ^"`
|
||||||
|
Out *tlb.MessagesList `tlb:"maybe ^"`
|
||||||
|
}{
|
||||||
|
In: &tlb.Message{
|
||||||
|
MsgType: tlb.MsgTypeInternal,
|
||||||
|
Msg: &tlb.InternalMessage{
|
||||||
|
SrcAddr: sender,
|
||||||
|
DstAddr: receive,
|
||||||
|
Amount: tlb.MustFromTON("1"),
|
||||||
|
Body: cell.BeginCell().EndCell(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
api := &fakeTonShardAPI{
|
||||||
|
shorts: []ton.TransactionShortInfo{
|
||||||
|
{Account: receive.Data(), LT: 2, Hash: bytes.Repeat([]byte{0x02}, 32)},
|
||||||
|
},
|
||||||
|
txByLT: map[uint64]*tlb.Transaction{2: tx},
|
||||||
|
}
|
||||||
|
state := &tonWatchState{
|
||||||
|
wallets: map[string]*address.Address{
|
||||||
|
addressutil.TonRawAddressObjectKey(receive): receive,
|
||||||
|
},
|
||||||
|
nativeToken: &mdb.ChainToken{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: service.TonNativeSymbol, Decimals: 9, Enabled: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
||||||
|
if err := processTonMasterBlock(context.Background(), api, master, state, newTonJettonWalletCache()); err != nil {
|
||||||
|
t.Fatalf("processTonMasterBlock(): %v", err)
|
||||||
|
}
|
||||||
|
if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != 2 {
|
||||||
|
t.Fatalf("fetched LTs = %#v, want [2]", api.fetchedLTs)
|
||||||
|
}
|
||||||
|
if len(api.fetchedAddrs) != 1 || api.fetchedAddrs[0] != addressutil.TonRawAddressObjectKey(receive) {
|
||||||
|
t.Fatalf("fetched addresses = %#v, want masterchain receive address", api.fetchedAddrs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTonShardBlockFailsOnIncompleteEmptyPage(t *testing.T) {
|
||||||
|
api := &fakeTonShardAPI{incomplete: true}
|
||||||
|
state := &tonWatchState{wallets: map[string]*address.Address{}}
|
||||||
|
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
||||||
|
shard := &ton.BlockIDExt{Workchain: 0, Shard: 0, SeqNo: 9}
|
||||||
|
|
||||||
|
err := processTonShardBlock(context.Background(), api, master, shard, state, newTonJettonWalletCache())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected incomplete empty page error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessTonShardBlockFailsWhenJettonWalletsCannotBeResolved(t *testing.T) {
|
||||||
|
receive := address.MustParseAddr("EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I")
|
||||||
|
sender := address.NewAddress(0, byte(receive.Workchain()), bytes.Repeat([]byte{0x88}, 32)).Bounce(false).Testnet(false)
|
||||||
|
tx := &tlb.Transaction{
|
||||||
|
LT: 2,
|
||||||
|
Now: uint32(time.Now().Unix()),
|
||||||
|
Hash: bytes.Repeat([]byte{0x99}, 32),
|
||||||
|
IO: struct {
|
||||||
|
In *tlb.Message `tlb:"maybe ^"`
|
||||||
|
Out *tlb.MessagesList `tlb:"maybe ^"`
|
||||||
|
}{
|
||||||
|
In: &tlb.Message{
|
||||||
|
MsgType: tlb.MsgTypeInternal,
|
||||||
|
Msg: &tlb.InternalMessage{
|
||||||
|
SrcAddr: sender,
|
||||||
|
DstAddr: receive,
|
||||||
|
Amount: tlb.MustFromTON("1"),
|
||||||
|
Body: cell.BeginCell().EndCell(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
api := &fakeTonShardAPI{
|
||||||
|
shorts: []ton.TransactionShortInfo{
|
||||||
|
{Account: receive.Data(), LT: 2, Hash: bytes.Repeat([]byte{0x02}, 32)},
|
||||||
|
},
|
||||||
|
txByLT: map[uint64]*tlb.Transaction{2: tx},
|
||||||
|
}
|
||||||
|
state := &tonWatchState{
|
||||||
|
wallets: map[string]*address.Address{
|
||||||
|
addressutil.TonRawAddressObjectKey(receive): receive,
|
||||||
|
},
|
||||||
|
jettonTokens: []mdb.ChainToken{
|
||||||
|
{BaseModel: mdb.BaseModel{ID: 1}, Network: mdb.NetworkTon, Symbol: "USDT", ContractAddress: "not-a-ton-address", Decimals: 6, Enabled: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
master := &ton.BlockIDExt{Workchain: address.MasterchainID, Shard: tonMasterShard, SeqNo: 10}
|
||||||
|
shard := &ton.BlockIDExt{Workchain: receive.Workchain(), Shard: 0, SeqNo: 9}
|
||||||
|
err := processTonShardBlock(context.Background(), api, master, shard, state, newTonJettonWalletCache())
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected jetton wallet resolution failure")
|
||||||
|
}
|
||||||
|
if len(api.fetchedLTs) != 1 || api.fetchedLTs[0] != 2 {
|
||||||
|
t.Fatalf("fetched LTs = %#v, want [2]", api.fetchedLTs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
addressutil "github.com/GMWalletApp/epusdt/util/address"
|
||||||
"github.com/btcsuite/btcutil/base58"
|
"github.com/btcsuite/btcutil/base58"
|
||||||
"github.com/gagliardetto/solana-go"
|
"github.com/gagliardetto/solana-go"
|
||||||
)
|
)
|
||||||
@@ -53,6 +54,9 @@ func isValidAddressByNetwork(network, addr string) bool {
|
|||||||
return isValidTronAddress(addr)
|
return isValidTronAddress(addr)
|
||||||
case mdb.NetworkSolana:
|
case mdb.NetworkSolana:
|
||||||
return isValidSolanaAddress(addr)
|
return isValidSolanaAddress(addr)
|
||||||
|
case mdb.NetworkTon:
|
||||||
|
_, err := addressutil.ParseTonMainnetAddress(addr)
|
||||||
|
return err == nil
|
||||||
default:
|
default:
|
||||||
// 其余 EVM 链统一使用 0x 地址校验
|
// 其余 EVM 链统一使用 0x 地址校验
|
||||||
return isValidEthereumAddress(addr)
|
return isValidEthereumAddress(addr)
|
||||||
@@ -64,6 +68,11 @@ func normalizeWalletAddressByNetwork(network, addr string) string {
|
|||||||
switch strings.ToLower(strings.TrimSpace(network)) {
|
switch strings.ToLower(strings.TrimSpace(network)) {
|
||||||
case mdb.NetworkTron, mdb.NetworkSolana:
|
case mdb.NetworkTron, mdb.NetworkSolana:
|
||||||
return addr
|
return addr
|
||||||
|
case mdb.NetworkTon:
|
||||||
|
if normalized, err := addressutil.NormalizeTonAddress(addr); err == nil {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
return addr
|
||||||
default:
|
default:
|
||||||
return strings.ToLower(addr)
|
return strings.ToLower(addr)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package addressutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
|
"github.com/xssnick/tonutils-go/crc16"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TonParsedAddress carries the canonical display and raw forms used by epusdt.
|
||||||
|
type TonParsedAddress struct {
|
||||||
|
Address *address.Address
|
||||||
|
Display string
|
||||||
|
Raw string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseTonMainnetAddress accepts raw and user-friendly TON addresses and rejects
|
||||||
|
// user-friendly testnet-only addresses. Raw addresses do not contain a testnet
|
||||||
|
// flag, so they are treated as mainnet addresses by definition.
|
||||||
|
func ParseTonMainnetAddress(input string) (*TonParsedAddress, error) {
|
||||||
|
raw := strings.TrimSpace(input)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmt.Errorf("ton address is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
addr *address.Address
|
||||||
|
err error
|
||||||
|
checkFlags bool
|
||||||
|
)
|
||||||
|
if strings.Contains(raw, ":") {
|
||||||
|
addr, err = address.ParseRawAddr(raw)
|
||||||
|
} else {
|
||||||
|
addr, err = parseUserFriendly(raw)
|
||||||
|
checkFlags = true
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if addr == nil || addr.Type() != address.StdAddress || addr.IsAddrNone() {
|
||||||
|
return nil, fmt.Errorf("unsupported ton address type")
|
||||||
|
}
|
||||||
|
if checkFlags && addr.IsTestnetOnly() {
|
||||||
|
return nil, fmt.Errorf("testnet-only ton address is not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
mainnet := addr.Bounce(false).Testnet(false)
|
||||||
|
return &TonParsedAddress{
|
||||||
|
Address: mainnet,
|
||||||
|
Display: mainnet.String(),
|
||||||
|
Raw: strings.ToLower(mainnet.StringRaw()),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeTonAddress returns the canonical user-friendly address stored in DB.
|
||||||
|
func NormalizeTonAddress(input string) (string, error) {
|
||||||
|
parsed, err := ParseTonMainnetAddress(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parsed.Display, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TonRawAddressKey returns the workchain:hash key used for comparisons and locks.
|
||||||
|
func TonRawAddressKey(input string) (string, error) {
|
||||||
|
parsed, err := ParseTonMainnetAddress(input)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return parsed.Raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeTonAddressObject returns the canonical user-friendly form for a parsed
|
||||||
|
// address object.
|
||||||
|
func NormalizeTonAddressObject(addr *address.Address) string {
|
||||||
|
if addr == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return addr.Bounce(false).Testnet(false).String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TonRawAddressObjectKey returns the raw workchain:hash key for a parsed address.
|
||||||
|
func TonRawAddressObjectKey(addr *address.Address) string {
|
||||||
|
if addr == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.ToLower(addr.Bounce(false).Testnet(false).StringRaw())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TonAddressFromShortAccount builds a standard TON address from a shard
|
||||||
|
// transaction account id returned by liteserver block transaction lists.
|
||||||
|
func TonAddressFromShortAccount(workchain int32, account []byte) *address.Address {
|
||||||
|
if len(account) != 32 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return address.NewAddress(0, byte(workchain), append([]byte(nil), account...)).Bounce(false).Testnet(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseUserFriendly(input string) (*address.Address, error) {
|
||||||
|
if addr, err := address.ParseAddr(input); err == nil {
|
||||||
|
return addr, nil
|
||||||
|
}
|
||||||
|
for _, enc := range []*base64.Encoding{
|
||||||
|
base64.RawStdEncoding,
|
||||||
|
base64.StdEncoding,
|
||||||
|
base64.URLEncoding,
|
||||||
|
} {
|
||||||
|
data, err := enc.DecodeString(input)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addr, err := addressFromFriendlyBytes(data)
|
||||||
|
if err == nil {
|
||||||
|
return addr, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("invalid ton address")
|
||||||
|
}
|
||||||
|
|
||||||
|
func addressFromFriendlyBytes(data []byte) (*address.Address, error) {
|
||||||
|
if len(data) != 36 {
|
||||||
|
return nil, fmt.Errorf("incorrect ton address data length")
|
||||||
|
}
|
||||||
|
want := binary.BigEndian.Uint16(data[34:])
|
||||||
|
if crc16.ChecksumXMODEM(data[:34]) != want {
|
||||||
|
return nil, fmt.Errorf("invalid ton address checksum")
|
||||||
|
}
|
||||||
|
if len(data[2:34]) != 32 {
|
||||||
|
return nil, fmt.Errorf("incorrect ton address hash length")
|
||||||
|
}
|
||||||
|
return address.NewAddress(data[0], data[1], append([]byte(nil), data[2:34]...)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package addressutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xssnick/tonutils-go/address"
|
||||||
|
)
|
||||||
|
|
||||||
|
const testTonBounceable = "EQC6KV4zs8TJtSZapOrRFmqSkxzpq-oSCoxekQRKElf4nC1I"
|
||||||
|
|
||||||
|
func TestParseTonMainnetAddressNormalizesSupportedFormats(t *testing.T) {
|
||||||
|
base := address.MustParseAddr(testTonBounceable)
|
||||||
|
raw := base.StringRaw()
|
||||||
|
nonBounce := base.Bounce(false).String()
|
||||||
|
rawBytes, err := base64.RawURLEncoding.DecodeString(testTonBounceable)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode fixture: %v", err)
|
||||||
|
}
|
||||||
|
stdBase64 := base64.RawStdEncoding.EncodeToString(rawBytes)
|
||||||
|
|
||||||
|
for _, input := range []string{testTonBounceable, nonBounce, raw, stdBase64} {
|
||||||
|
parsed, err := ParseTonMainnetAddress(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseTonMainnetAddress(%q): %v", input, err)
|
||||||
|
}
|
||||||
|
if parsed.Display != nonBounce {
|
||||||
|
t.Fatalf("display for %q = %q, want %q", input, parsed.Display, nonBounce)
|
||||||
|
}
|
||||||
|
if parsed.Raw != strings.ToLower(raw) {
|
||||||
|
t.Fatalf("raw for %q = %q, want %q", input, parsed.Raw, strings.ToLower(raw))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseTonMainnetAddressRejectsTestnetOnly(t *testing.T) {
|
||||||
|
testnet := address.MustParseAddr(testTonBounceable).Testnet(true).String()
|
||||||
|
if _, err := ParseTonMainnetAddress(testnet); err == nil {
|
||||||
|
t.Fatal("expected testnet-only address to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTonAddressFromShortAccount(t *testing.T) {
|
||||||
|
base := address.MustParseAddr(testTonBounceable)
|
||||||
|
got := TonAddressFromShortAccount(base.Workchain(), base.Data())
|
||||||
|
if got == nil {
|
||||||
|
t.Fatal("TonAddressFromShortAccount() returned nil")
|
||||||
|
}
|
||||||
|
if TonRawAddressObjectKey(got) != TonRawAddressObjectKey(base) {
|
||||||
|
t.Fatalf("raw address = %q, want %q", TonRawAddressObjectKey(got), TonRawAddressObjectKey(base))
|
||||||
|
}
|
||||||
|
if got = TonAddressFromShortAccount(base.Workchain(), base.Data()[:31]); got != nil {
|
||||||
|
t.Fatalf("TonAddressFromShortAccount() with short account = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+80
-11
@@ -2,6 +2,9 @@ package log
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/natefinch/lumberjack"
|
"github.com/natefinch/lumberjack"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -13,11 +16,16 @@ import (
|
|||||||
|
|
||||||
var Sugar *zap.SugaredLogger
|
var Sugar *zap.SugaredLogger
|
||||||
|
|
||||||
|
var (
|
||||||
|
atomicLevel = zap.NewAtomicLevelAt(zapcore.InfoLevel)
|
||||||
|
levelMu sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
func Init() {
|
func Init() {
|
||||||
level := getLogLevel()
|
atomicLevel.SetLevel(getLogLevel())
|
||||||
core := zapcore.NewTee(
|
core := zapcore.NewTee(
|
||||||
zapcore.NewCore(getConsoleEncoder(), getConsoleWriter(), level),
|
zapcore.NewCore(getConsoleEncoder(), getConsoleWriter(), atomicLevel),
|
||||||
zapcore.NewCore(getFileEncoder(), getFileWriter(), level),
|
zapcore.NewCore(getFileEncoder(), getFileWriter(), atomicLevel),
|
||||||
)
|
)
|
||||||
logger := zap.New(core, zap.AddCaller())
|
logger := zap.New(core, zap.AddCaller())
|
||||||
Sugar = logger.Sugar()
|
Sugar = logger.Sugar()
|
||||||
@@ -56,14 +64,75 @@ func getConsoleWriter() zapcore.WriteSyncer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getLogLevel() zapcore.Level {
|
func getLogLevel() zapcore.Level {
|
||||||
switch config.LogLevel {
|
level, _, err := parseLevel(config.LogLevel)
|
||||||
case "debug":
|
if err != nil {
|
||||||
return zapcore.DebugLevel
|
|
||||||
case "warn":
|
|
||||||
return zapcore.WarnLevel
|
|
||||||
case "error":
|
|
||||||
return zapcore.ErrorLevel
|
|
||||||
default:
|
|
||||||
return zapcore.InfoLevel
|
return zapcore.InfoLevel
|
||||||
}
|
}
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetLevel(level string) error {
|
||||||
|
nextLevel, normalized, err := parseLevel(level)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
levelMu.Lock()
|
||||||
|
defer levelMu.Unlock()
|
||||||
|
|
||||||
|
currentLevel := atomicLevel.Level()
|
||||||
|
current := levelToString(currentLevel)
|
||||||
|
if current == normalized {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
message := fmt.Sprintf("[log] level changed: %s -> %s", current, normalized)
|
||||||
|
if Sugar != nil && currentLevel <= zapcore.WarnLevel {
|
||||||
|
Sugar.Warn(message)
|
||||||
|
}
|
||||||
|
atomicLevel.SetLevel(nextLevel)
|
||||||
|
if Sugar != nil && currentLevel > zapcore.WarnLevel {
|
||||||
|
Sugar.Warn(message)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CurrentLevel() string {
|
||||||
|
return levelToString(atomicLevel.Level())
|
||||||
|
}
|
||||||
|
|
||||||
|
func NormalizeLevel(level string) (string, error) {
|
||||||
|
_, normalized, err := parseLevel(level)
|
||||||
|
return normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLevel(level string) (zapcore.Level, string, error) {
|
||||||
|
normalized := strings.ToLower(strings.TrimSpace(level))
|
||||||
|
switch normalized {
|
||||||
|
case "debug":
|
||||||
|
return zapcore.DebugLevel, normalized, nil
|
||||||
|
case "info":
|
||||||
|
return zapcore.InfoLevel, normalized, nil
|
||||||
|
case "warn":
|
||||||
|
return zapcore.WarnLevel, normalized, nil
|
||||||
|
case "error":
|
||||||
|
return zapcore.ErrorLevel, normalized, nil
|
||||||
|
default:
|
||||||
|
return zapcore.InfoLevel, "", fmt.Errorf("unsupported log level %q", level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func levelToString(level zapcore.Level) string {
|
||||||
|
switch level {
|
||||||
|
case zapcore.DebugLevel:
|
||||||
|
return "debug"
|
||||||
|
case zapcore.InfoLevel:
|
||||||
|
return "info"
|
||||||
|
case zapcore.WarnLevel:
|
||||||
|
return "warn"
|
||||||
|
case zapcore.ErrorLevel:
|
||||||
|
return "error"
|
||||||
|
default:
|
||||||
|
return level.String()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"go.uber.org/zap/zapcore"
|
||||||
|
"go.uber.org/zap/zaptest/observer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSetLevelUpdatesAtomicLevelAndLogsChange(t *testing.T) {
|
||||||
|
oldSugar := Sugar
|
||||||
|
oldLevel := atomicLevel.Level()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
Sugar = oldSugar
|
||||||
|
atomicLevel.SetLevel(oldLevel)
|
||||||
|
})
|
||||||
|
|
||||||
|
core, observed := observer.New(zapcore.WarnLevel)
|
||||||
|
Sugar = zap.New(core).Sugar()
|
||||||
|
atomicLevel.SetLevel(zapcore.ErrorLevel)
|
||||||
|
|
||||||
|
if err := SetLevel(" DEBUG "); err != nil {
|
||||||
|
t.Fatalf("SetLevel(DEBUG): %v", err)
|
||||||
|
}
|
||||||
|
if got := CurrentLevel(); got != "debug" {
|
||||||
|
t.Fatalf("CurrentLevel = %q, want debug", got)
|
||||||
|
}
|
||||||
|
logs := observed.FilterMessage("[log] level changed: error -> debug").All()
|
||||||
|
if len(logs) != 1 {
|
||||||
|
t.Fatalf("level change logs = %d, want 1; all=%v", len(logs), observed.All())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetLevel("debug"); err != nil {
|
||||||
|
t.Fatalf("SetLevel(debug no-op): %v", err)
|
||||||
|
}
|
||||||
|
logs = observed.FilterMessage("[log] level changed: error -> debug").All()
|
||||||
|
if len(logs) != 1 {
|
||||||
|
t.Fatalf("no-op level change emitted extra logs: %d", len(logs))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := SetLevel("fatal"); err == nil {
|
||||||
|
t.Fatal("SetLevel(fatal) succeeded, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./unauthorized-error-CASnS-6J.js";var t=e;export{t as component};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./unauthorized-error-Dv-DRB5e.js";var t=e;export{t as component};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./forbidden-peSSCojP.js";var t=e;export{t as component};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./forbidden-CHYUR1RB.js";var t=e;export{t as component};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./not-found-error-CTygh2Q3.js";var t=e;export{t as component};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./not-found-error-AnL_Yh5s.js";var t=e;export{t as component};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./general-error-CDe5w_m4.js";var t=e;export{t as component};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./general-error-CkWgD-Kh.js";var t=e;export{t as component};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./maintenance-error-C9XjssOV.js";var t=e;export{t as component};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{t as e}from"./maintenance-error-CpldkShG.js";var t=e;export{t as component};
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Pp as n}from"./messages-d1hRBHLP.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-CMPEj0pJ.js";import"./redirect-DMNGvjzO.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-9UShCljL.js";import{n as p}from"./matchContext-BFCdcHzo.js";import{t as m}from"./atom-DQpaPCwp.js";function h(e){if(typeof document<`u`&&document.querySelector){let t=e.stores.location.get(),n=t.state.__hashScrollIntoViewOptions??!0;if(n&&t.hash!==``){let e=document.getElementById(t.hash);e&&e.scrollIntoView(n)}}}var g=e(t(),1);function _(){let e=a(),t=g.useRef({router:e,mounted:!1}),[n,c]=g.useState(!1),l=o(e.stores.isLoading,e=>e),u=o(e.stores.hasPending,e=>e),f=i(l),p=l||n||u,_=i(p),v=l||u,y=i(v);return e.startTransition=e=>{c(!0),g.startTransition(()=>{e(),c(!1)})},g.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return s(e.latestLocation.publicHref)!==s(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),r(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),r(()=>{f&&!l&&e.emit({type:`onLoad`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[f,e,l]),r(()=>{y&&!v&&e.emit({type:`onBeforeRouteMount`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[v,y,e]),r(()=>{if(_&&!p){let t=d(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),m(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())}),t.hrefChanged&&h(e)}},[p,_,e]),null}var v=n();function y(){let e=a(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,v.jsx)(t,{}):null,r=(0,v.jsxs)(typeof document<`u`&&e.ssr?u:g.Suspense,{fallback:n,children:[(0,v.jsx)(_,{}),(0,v.jsx)(b,{})]});return e.options.InnerWrap?(0,v.jsx)(e.options.InnerWrap,{children:r}):r}function b(){let e=a(),t=o(e.stores.firstId,e=>e),n=o(e.stores.loadedAt,e=>e),r=t?(0,v.jsx)(f,{matchId:t}):null;return(0,v.jsx)(p.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,v.jsx)(l,{getResetKey:()=>n,errorComponent:c,onCatch:void 0,children:r})})}function x(){let e=a();return o(e.stores.matchRouteDeps,e=>e),g.useCallback(t=>{let{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a,...o}=t;return e.matchRoute(o,{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a})},[e])}export{x as n,y as t};
|
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Zp as n}from"./messages-BLwDcEAr.js";import{o as r,s as i,t as a}from"./useRouter-DtTm7XkK.js";import{t as o}from"./useStore-CupyIEEP.js";import{f as s}from"./ClientOnly-CWlB586P.js";import"./redirect-DMNGvjzO.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-CeXMJwDZ.js";import{n as p}from"./matchContext-BFCdcHzo.js";import{t as m}from"./atom-DQpaPCwp.js";function h(e){if(typeof document<`u`&&document.querySelector){let t=e.stores.location.get(),n=t.state.__hashScrollIntoViewOptions??!0;if(n&&t.hash!==``){let e=document.getElementById(t.hash);e&&e.scrollIntoView(n)}}}var g=e(t(),1);function _(){let e=a(),t=g.useRef({router:e,mounted:!1}),[n,c]=g.useState(!1),l=o(e.stores.isLoading,e=>e),u=o(e.stores.hasPending,e=>e),f=i(l),p=l||n||u,_=i(p),v=l||u,y=i(v);return e.startTransition=e=>{c(!0),g.startTransition(()=>{e(),c(!1)})},g.useEffect(()=>{let t=e.history.subscribe(e.load),n=e.buildLocation({to:e.latestLocation.pathname,search:!0,params:!0,hash:!0,state:!0,_includeValidateSearch:!0});return s(e.latestLocation.publicHref)!==s(n.publicHref)&&e.commitLocation({...n,replace:!0}),()=>{t()}},[e,e.history]),r(()=>{typeof window<`u`&&e.ssr||t.current.router===e&&t.current.mounted||(t.current={router:e,mounted:!0},(async()=>{try{await e.load()}catch(e){console.error(e)}})())},[e]),r(()=>{f&&!l&&e.emit({type:`onLoad`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[f,e,l]),r(()=>{y&&!v&&e.emit({type:`onBeforeRouteMount`,...d(e.stores.location.get(),e.stores.resolvedLocation.get())})},[v,y,e]),r(()=>{if(_&&!p){let t=d(e.stores.location.get(),e.stores.resolvedLocation.get());e.emit({type:`onResolved`,...t}),m(()=>{e.stores.status.set(`idle`),e.stores.resolvedLocation.set(e.stores.location.get())}),t.hrefChanged&&h(e)}},[p,_,e]),null}var v=n();function y(){let e=a(),t=e.routesById.__root__.options.pendingComponent??e.options.defaultPendingComponent,n=t?(0,v.jsx)(t,{}):null,r=(0,v.jsxs)(typeof document<`u`&&e.ssr?u:g.Suspense,{fallback:n,children:[(0,v.jsx)(_,{}),(0,v.jsx)(b,{})]});return e.options.InnerWrap?(0,v.jsx)(e.options.InnerWrap,{children:r}):r}function b(){let e=a(),t=o(e.stores.firstId,e=>e),n=o(e.stores.loadedAt,e=>e),r=t?(0,v.jsx)(f,{matchId:t}):null;return(0,v.jsx)(p.Provider,{value:t,children:e.options.disableGlobalCatchBoundary?r:(0,v.jsx)(l,{getResetKey:()=>n,errorComponent:c,onCatch:void 0,children:r})})}function x(){let e=a();return o(e.stores.matchRouteDeps,e=>e),g.useCallback(t=>{let{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a,...o}=t;return e.matchRoute(o,{pending:n,caseSensitive:r,fuzzy:i,includeSearch:a})},[e])}export{x as n,y as t};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{h as e}from"./editor.api2-BpueNIaM.js";import"./toggleHighContrast-DRztGxhw.js";var t={},n={},r=class e{static getOrCreate(t){return n[t]||(n[t]=new e(t)),n[t]}constructor(e){this._languageId=e,this._loadingTriggered=!1,this._lazyLoadPromise=new Promise((e,t)=>{this._lazyLoadPromiseResolve=e,this._lazyLoadPromiseReject=t})}load(){return this._loadingTriggered||(this._loadingTriggered=!0,t[this._languageId].loader().then(e=>this._lazyLoadPromiseResolve(e),e=>this._lazyLoadPromiseReject(e))),this._lazyLoadPromise}};function i(n){let i=n.id;t[i]=n,e.register(n);let a=r.getOrCreate(i);e.registerTokensProviderFactory(i,{create:async()=>(await a.load()).language}),e.onLanguageEncountered(i,async()=>{let t=await a.load();e.setLanguageConfiguration(i,t.conf)})}export{i as t};
|
import{h as e}from"./editor.api2-BM_YT0wu.js";import"./toggleHighContrast-CYWWoXog.js";var t={},n={},r=class e{static getOrCreate(t){return n[t]||(n[t]=new e(t)),n[t]}constructor(e){this._languageId=e,this._loadingTriggered=!1,this._lazyLoadPromise=new Promise((e,t)=>{this._lazyLoadPromiseResolve=e,this._lazyLoadPromiseReject=t})}load(){return this._loadingTriggered||(this._loadingTriggered=!0,t[this._languageId].loader().then(e=>this._lazyLoadPromiseResolve(e),e=>this._lazyLoadPromiseReject(e))),this._lazyLoadPromise}};function i(n){let i=n.id;t[i]=n,e.register(n);let a=r.getOrCreate(i);e.registerTokensProviderFactory(i,{create:async()=>(await a.load()).language}),e.onLanguageEncountered(i,async()=>{let t=await a.load();e.setLanguageConfiguration(i,t.conf)})}export{i as t};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{Zp as e}from"./messages-BLwDcEAr.js";import{m as t}from"./search-provider-B67mlhed.js";import{n,t as r}from"./theme-switch-B3KcLEky.js";import{t as i}from"./general-error-CDe5w_m4.js";import{t as a}from"./not-found-error-CTygh2Q3.js";import{t as o}from"./_error-wx-ubgoM.js";import{t as s}from"./unauthorized-error-CASnS-6J.js";import{t as c}from"./forbidden-peSSCojP.js";import{t as l}from"./maintenance-error-C9XjssOV.js";import{n as u,r as d,t as f}from"./header-NBnXYgM_.js";var p=e();function m(){let{error:e}=o.useParams(),m={unauthorized:s,forbidden:c,"not-found":a,"internal-server-error":i,"maintenance-error":l}[e]||a;return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)(f,{className:`border-b`,fixed:!0,children:[(0,p.jsx)(u,{}),(0,p.jsxs)(`div`,{className:`ms-auto flex items-center space-x-4`,children:[(0,p.jsx)(n,{}),(0,p.jsx)(r,{}),(0,p.jsx)(t,{}),(0,p.jsx)(d,{})]})]}),(0,p.jsx)(`div`,{className:`flex-1 [&>div]:h-full`,children:(0,p.jsx)(m,{})})]})}export{m as component};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{Pp as e}from"./messages-d1hRBHLP.js";import{m as t}from"./search-provider-Zrg-SPXk.js";import{n,t as r}from"./theme-switch-DporjlJW.js";import{t as i}from"./general-error-CkWgD-Kh.js";import{t as a}from"./not-found-error-AnL_Yh5s.js";import{t as o}from"./_error-DqSF-qO2.js";import{t as s}from"./unauthorized-error-Dv-DRB5e.js";import{t as c}from"./forbidden-CHYUR1RB.js";import{t as l}from"./maintenance-error-CpldkShG.js";import{n as u,r as d,t as f}from"./header-BZlnQmen.js";var p=e();function m(){let{error:e}=o.useParams(),m={unauthorized:s,forbidden:c,"not-found":a,"internal-server-error":i,"maintenance-error":l}[e]||a;return(0,p.jsxs)(p.Fragment,{children:[(0,p.jsxs)(f,{className:`border-b`,fixed:!0,children:[(0,p.jsx)(u,{}),(0,p.jsxs)(`div`,{className:`ms-auto flex items-center space-x-4`,children:[(0,p.jsx)(n,{}),(0,p.jsx)(r,{}),(0,p.jsx)(t,{}),(0,p.jsx)(d,{})]})]}),(0,p.jsx)(`div`,{className:`flex-1 [&>div]:h-full`,children:(0,p.jsx)(m,{})})]})}export{m as component};
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-D4dSyXw0.js","assets/search-provider-Zrg-SPXk.js","assets/chunk-DECur_0Z.js","assets/dist-CRowTfGU.js","assets/dist-CIovDv3v.js","assets/messages-d1hRBHLP.js","assets/react-CO2uhaBc.js","assets/confirm-dialog-CIBixfaW.js","assets/button-CbdgUDvS.js","assets/clsx-D9aGYY3V.js","assets/dist-BjDKUEMi.js","assets/dist-ByVfo7qC.js","assets/dist-qAA-axL4.js","assets/dist-CXfAGnWR.js","assets/es2015-Chgs9D7p.js","assets/dist-xVhEP_Vg.js","assets/dist-s_CiB-DN.js","assets/dist-BlTmpVme.js","assets/dist-D13PZ7es.js","assets/dist-DOx-EAQr.js","assets/dist-BixeH3nX.js","assets/dist-DuNl0ZwE.js","assets/separator-CE12xF6y.js","assets/tooltip-DuzUBeLr.js","assets/dist-BIXdBdVU.js","assets/dist-BGJ_1VEi.js","assets/auth-store-GibZbQPL.js","assets/dist-Bk35TVDN.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useRouter-DtTm7XkK.js","assets/useNavigate-7u4DX0Ho.js","assets/useStore-CupyIEEP.js","assets/command-DIyNKYlo.js","assets/createLucideIcon-Br0Bd5k2.js","assets/dialog-SgRLLGoQ.js","assets/skeleton-DwLAyCLM.js","assets/wallet-CkKQfl5z.js","assets/sun-BwqrnykR.js","assets/shield-check-CiWq1V4z.js","assets/logo-CGyEdT5T.js","assets/input-CusCjhBW.js","assets/font-provider-aBht_Njp.js","assets/theme-provider-BXElecjF.js","assets/theme-switch-DporjlJW.js","assets/dropdown-menu-DwTFINO1.js","assets/check-DldThLle.js","assets/header-BZlnQmen.js","assets/link-Br2JDLvw.js","assets/ClientOnly-CMPEj0pJ.js","assets/forbidden-CHYUR1RB.js","assets/general-error-CkWgD-Kh.js","assets/maintenance-error-CpldkShG.js","assets/not-found-error-AnL_Yh5s.js","assets/unauthorized-error-Dv-DRB5e.js"])))=>i.map(i=>d[i]);
|
|
||||||
import{n as e,t}from"./lazyRouteComponent-CFOfOLgA.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/_authenticated/errors/$error`)({component:t(()=>n(()=>import(`./_error-D4dSyXw0.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54])),`component`)});export{r as t};
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-CvIS-wQi.js","assets/search-provider-B67mlhed.js","assets/chunk-DECur_0Z.js","assets/dist-CRowTfGU.js","assets/dist-D343v8gR.js","assets/messages-BLwDcEAr.js","assets/react-CO2uhaBc.js","assets/confirm-dialog-GGoKuiu9.js","assets/button-C0SPdqvs.js","assets/clsx-D9aGYY3V.js","assets/dist-BEGLjtzw.js","assets/dist-Cd7-TPaR.js","assets/dist-BphrRiSn.js","assets/dist-XiAcd8rT.js","assets/es2015-hwcIKy2q.js","assets/dist-C9d8iJDv.js","assets/dist-CYQVWHzW.js","assets/dist-CVKHDB42.js","assets/dist-DNb3IZ7I.js","assets/dist-THZWKIyP.js","assets/dist-BixeH3nX.js","assets/dist-DJTK1B2D.js","assets/separator-Di9zEmE_.js","assets/tooltip-8Hby-rrx.js","assets/dist-B1bXEkK2.js","assets/dist-Cm0GVWLR.js","assets/auth-store-paR9oIR_.js","assets/dist-CffCYhqm.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useRouter-DtTm7XkK.js","assets/useNavigate-7u4DX0Ho.js","assets/useStore-CupyIEEP.js","assets/command-vg2xchuv.js","assets/createLucideIcon-Br0Bd5k2.js","assets/dialog-iqE0q8bN.js","assets/skeleton-rs-vmKNc.js","assets/wallet-Bn5Vi2Ko.js","assets/sun-JU8p4FoE.js","assets/shield-check-MAPDL1u8.js","assets/logo-GNJA7dBE.js","assets/input-D-qaLZKc.js","assets/font-provider-BogPqUvU.js","assets/theme-provider-Cs4UKh_6.js","assets/theme-switch-B3KcLEky.js","assets/dropdown-menu-CDbt-i_J.js","assets/check-CHp0nSkR.js","assets/header-NBnXYgM_.js","assets/link-uW89koiO.js","assets/ClientOnly-CWlB586P.js","assets/forbidden-peSSCojP.js","assets/general-error-CDe5w_m4.js","assets/maintenance-error-C9XjssOV.js","assets/not-found-error-CTygh2Q3.js","assets/unauthorized-error-CASnS-6J.js"])))=>i.map(i=>d[i]);
|
||||||
|
import{n as e,t}from"./lazyRouteComponent-BeVeaHRY.js";import{t as n}from"./preload-helper-DoS0eHac.js";var r=e(`/_authenticated/errors/$error`)({component:t(()=>n(()=>import(`./_error-CvIS-wQi.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54])),`component`)});export{r as t};
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-Cd6oGTRE.js","assets/chunk-DECur_0Z.js","assets/button-C0SPdqvs.js","assets/clsx-D9aGYY3V.js","assets/messages-BLwDcEAr.js","assets/react-CO2uhaBc.js","assets/select-CNrSwHJR.js","assets/dist-B1bXEkK2.js","assets/dist-D343v8gR.js","assets/dist-BphrRiSn.js","assets/dist-XiAcd8rT.js","assets/dist-DJTK1B2D.js","assets/dist-CRowTfGU.js","assets/dist-THZWKIyP.js","assets/dist-CYQVWHzW.js","assets/dist-Cd7-TPaR.js","assets/es2015-hwcIKy2q.js","assets/dist-BixeH3nX.js","assets/dist-Cm0GVWLR.js","assets/createLucideIcon-Br0Bd5k2.js","assets/check-CHp0nSkR.js","assets/chevron-down-BB5h1CsX.js","assets/auth-store-paR9oIR_.js","assets/dist-CffCYhqm.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useNavigate-7u4DX0Ho.js","assets/useRouter-DtTm7XkK.js","assets/copy-to-clipboard-C1tj4a8X.js","assets/arrow-left-right-CcCrkQiR.js","assets/arrow-left-BpZwGREZ.js","assets/radio-group-D__IFVa8.js","assets/dist-CVKHDB42.js","assets/dist-C9d8iJDv.js","assets/dist-DNb3IZ7I.js","assets/loader-circle-DpEz1fQv.js","assets/send-BNvceEpO.js","assets/triangle-alert-DB5v_9sS.js","assets/dialog-iqE0q8bN.js","assets/dist-BEGLjtzw.js","assets/crypto-icon-CJd7sdrZ.js","assets/labels-BiExVADV.js","assets/input-D-qaLZKc.js","assets/checkout-model-CQ3aqlob.js"])))=>i.map(i=>d[i]);
|
||||||
|
import{n as e,t}from"./lazyRouteComponent-BeVeaHRY.js";import{t as n}from"./preload-helper-DoS0eHac.js";var r=e(`/cashier/$trade_id/`)({component:t(()=>n(()=>import(`./_trade_id-Cd6oGTRE.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43])),`component`)});export{r as t};
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,2 +0,0 @@
|
|||||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-Ck9STVZb.js","assets/chunk-DECur_0Z.js","assets/button-CbdgUDvS.js","assets/clsx-D9aGYY3V.js","assets/messages-d1hRBHLP.js","assets/react-CO2uhaBc.js","assets/select-Cv_sRi68.js","assets/dist-BIXdBdVU.js","assets/dist-CIovDv3v.js","assets/dist-qAA-axL4.js","assets/dist-CXfAGnWR.js","assets/dist-DuNl0ZwE.js","assets/dist-CRowTfGU.js","assets/dist-DOx-EAQr.js","assets/dist-s_CiB-DN.js","assets/dist-ByVfo7qC.js","assets/es2015-Chgs9D7p.js","assets/dist-BixeH3nX.js","assets/dist-BGJ_1VEi.js","assets/createLucideIcon-Br0Bd5k2.js","assets/check-DldThLle.js","assets/chevron-down-BPPKEraq.js","assets/auth-store-GibZbQPL.js","assets/dist-Bk35TVDN.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-CX51BC9z.js","assets/dist-BlTmpVme.js","assets/dist-xVhEP_Vg.js","assets/dist-D13PZ7es.js","assets/loader-circle-DRj7MiMc.js","assets/send-g-CrrC4k.js","assets/triangle-alert-BEkslGAT.js","assets/dialog-SgRLLGoQ.js","assets/dist-BjDKUEMi.js","assets/crypto-icon-BwB-F_ID.js","assets/labels-CfnKbsNx.js","assets/input-CusCjhBW.js","assets/checkout-model-CY0q1fms.js"])))=>i.map(i=>d[i]);
|
|
||||||
import{n as e,t}from"./lazyRouteComponent-CFOfOLgA.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/cashier/$trade_id/`)({component:t(()=>n(()=>import(`./_trade_id-Ck9STVZb.js`),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42])),`component`)});export{r as t};
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{Cp as e,Pp as t,Sp as n,_n as r,bn as i,gn as a,hn as o,mn as s,vn as c,yn as l}from"./messages-d1hRBHLP.js";import{i as u,n as d,t as f}from"./button-CbdgUDvS.js";import{n as p,r as m}from"./font-provider-aBht_Njp.js";import{n as h}from"./theme-provider-BXElecjF.js";import{t as g}from"./chevron-down-BPPKEraq.js";import{n as _,t as v}from"./radio-group-CX51BC9z.js";import{r as y,s as b}from"./zod-CQemhjci.js";import{a as x,c as S,i as C,l as w,n as T,o as E,r as D,s as O,t as k}from"./form-Db7d571a.js";import{t as A}from"./page-header-DkeFH2Rh.js";import{t as j}from"./show-submitted-data-CfmGDLbU.js";var M=t(),N=b({theme:y([`light`,`dark`]),font:y(m)});function P(){let{font:t,setFont:i}=p(),{theme:l,setTheme:y}=h(),b={theme:l,font:t},A=w({resolver:S(N),defaultValues:b});function P(e){e.font!==t&&i(e.font),e.theme!==l&&y(e.theme),j(e)}return(0,M.jsx)(k,{...A,children:(0,M.jsxs)(`form`,{className:`space-y-8`,onSubmit:A.handleSubmit(P),children:[(0,M.jsx)(C,{control:A.control,name:`font`,render:({field:e})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:c()}),(0,M.jsxs)(`div`,{className:`relative w-max`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(`select`,{className:u(d({variant:`outline`}),`w-50 appearance-none font-normal capitalize`,`dark:bg-background dark:hover:bg-background`),...e,children:m.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))})}),(0,M.jsx)(g,{className:`absolute inset-e-3 top-2.5 h-4 w-4 opacity-50`})]}),(0,M.jsx)(D,{className:`font-manrope`,children:r()}),(0,M.jsx)(O,{})]})}),(0,M.jsx)(C,{control:A.control,name:`theme`,render:({field:t})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:a()}),(0,M.jsx)(D,{children:o()}),(0,M.jsx)(O,{}),(0,M.jsxs)(v,{className:`grid max-w-md grid-cols-2 gap-8 pt-2`,defaultValue:t.value,onValueChange:t.onChange,children:[(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`light`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted p-1 hover:border-accent`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-[#ecedef] p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:e()})]})}),(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`dark`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-slate-950 p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:n()})]})})]})]})}),(0,M.jsx)(f,{type:`submit`,children:s()})]})})}function F(){return(0,M.jsx)(A,{description:l(),title:i(),variant:`section`,children:(0,M.jsx)(P,{})})}var I=F;export{I as component};
|
import{Bp as e,Cn as t,Sn as n,Zp as r,_n as i,bn as a,vn as o,xn as s,yn as c,zp as l}from"./messages-BLwDcEAr.js";import{i as u,n as d,t as f}from"./button-C0SPdqvs.js";import{n as p,r as m}from"./font-provider-BogPqUvU.js";import{n as h}from"./theme-provider-Cs4UKh_6.js";import{t as g}from"./chevron-down-BB5h1CsX.js";import{n as _,t as v}from"./radio-group-D__IFVa8.js";import{r as y,s as b}from"./zod-rwFXxHlg.js";import{a as x,c as S,i as C,l as w,n as T,o as E,r as D,s as O,t as k}from"./form-fulEvgfz.js";import{t as A}from"./page-header-5Y8t-L2D.js";import{t as j}from"./show-submitted-data-COdV4uz4.js";var M=r(),N=b({theme:y([`light`,`dark`]),font:y(m)});function P(){let{font:t,setFont:n}=p(),{theme:r,setTheme:y}=h(),b={theme:r,font:t},A=w({resolver:S(N),defaultValues:b});function P(e){e.font!==t&&n(e.font),e.theme!==r&&y(e.theme),j(e)}return(0,M.jsx)(k,{...A,children:(0,M.jsxs)(`form`,{className:`space-y-8`,onSubmit:A.handleSubmit(P),children:[(0,M.jsx)(C,{control:A.control,name:`font`,render:({field:e})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:s()}),(0,M.jsxs)(`div`,{className:`relative w-max`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(`select`,{className:u(d({variant:`outline`}),`w-50 appearance-none font-normal capitalize`,`dark:bg-background dark:hover:bg-background`),...e,children:m.map(e=>(0,M.jsx)(`option`,{value:e,children:e},e))})}),(0,M.jsx)(g,{className:`absolute inset-e-3 top-2.5 h-4 w-4 opacity-50`})]}),(0,M.jsx)(D,{className:`font-manrope`,children:a()}),(0,M.jsx)(O,{})]})}),(0,M.jsx)(C,{control:A.control,name:`theme`,render:({field:t})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:c()}),(0,M.jsx)(D,{children:o()}),(0,M.jsx)(O,{}),(0,M.jsxs)(v,{className:`grid max-w-md grid-cols-2 gap-8 pt-2`,defaultValue:t.value,onValueChange:t.onChange,children:[(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`light`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted p-1 hover:border-accent`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-[#ecedef] p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:e()})]})}),(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`dark`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted bg-popover p-1 hover:bg-accent hover:text-accent-foreground`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-slate-950 p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-slate-800 p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-slate-400`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-slate-400`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:l()})]})})]})]})}),(0,M.jsx)(f,{type:`submit`,children:i()})]})})}function F(){return(0,M.jsx)(A,{description:n(),title:t(),variant:`section`,children:(0,M.jsx)(P,{})})}var I=F;export{I as component};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{t as e}from"./createLucideIcon-Br0Bd5k2.js";var t=e(`arrow-left-right`,[[`path`,{d:`M8 3 4 7l4 4`,key:`9rb6wj`}],[`path`,{d:`M4 7h16`,key:`6tx8e3`}],[`path`,{d:`m16 21 4-4-4-4`,key:`siv7j2`}],[`path`,{d:`M20 17H4`,key:`h6l3hr`}]]);export{t};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Pp as n}from"./messages-d1hRBHLP.js";var r=e(t(),1),i=n(),a=(0,r.createContext)({isTyping:!1,passwordLength:0,setIsTyping:()=>void 0,setPasswordLength:()=>void 0,setShowPassword:()=>void 0,showPassword:!1});function o({children:e}){let[t,n]=(0,r.useState)(!1),[o,s]=(0,r.useState)(!1),[c,l]=(0,r.useState)(0);return(0,i.jsx)(a.Provider,{value:{isTyping:t,passwordLength:c,setIsTyping:n,setPasswordLength:l,setShowPassword:s,showPassword:o},children:e})}function s(){return(0,r.useContext)(a)}export{s as n,o as t};
|
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Zp as n}from"./messages-BLwDcEAr.js";var r=e(t(),1),i=n(),a=(0,r.createContext)({isTyping:!1,passwordLength:0,setIsTyping:()=>void 0,setPasswordLength:()=>void 0,setShowPassword:()=>void 0,showPassword:!1});function o({children:e}){let[t,n]=(0,r.useState)(!1),[o,s]=(0,r.useState)(!1),[c,l]=(0,r.useState)(0);return(0,i.jsx)(a.Provider,{value:{isTyping:t,passwordLength:c,setIsTyping:n,setPasswordLength:l,setShowPassword:s,showPassword:o},children:e})}function s(){return(0,r.useContext)(a)}export{s as n,o as t};
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{Pp as e}from"./messages-d1hRBHLP.js";import{i as t,o as n,r}from"./button-CbdgUDvS.js";var i=e(),a=r(`inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3`,{variants:{variant:{default:`bg-primary text-primary-foreground [a&]:hover:bg-primary/90`,secondary:`bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90`,destructive:`bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90`,outline:`border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,ghost:`[a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,link:`text-primary underline-offset-4 [a&]:hover:underline`}},defaultVariants:{variant:`default`}});function o({className:e,variant:r=`default`,asChild:o=!1,...s}){return(0,i.jsx)(o?n:`span`,{className:t(a({variant:r}),e),"data-slot":`badge`,"data-variant":r,...s})}export{o as t};
|
import{Zp as e}from"./messages-BLwDcEAr.js";import{i as t,o as n,r}from"./button-C0SPdqvs.js";var i=e(),a=r(`inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-full border border-transparent px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3`,{variants:{variant:{default:`bg-primary text-primary-foreground [a&]:hover:bg-primary/90`,secondary:`bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90`,destructive:`bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90`,outline:`border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,ghost:`[a&]:hover:bg-accent [a&]:hover:text-accent-foreground`,link:`text-primary underline-offset-4 [a&]:hover:underline`}},defaultVariants:{variant:`default`}});function o({className:e,variant:r=`default`,asChild:o=!1,...s}){return(0,i.jsx)(o?n:`span`,{className:t(a({variant:r}),e),"data-slot":`badge`,"data-variant":r,...s})}export{o as t};
|
||||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{Pp as e}from"./messages-d1hRBHLP.js";import{i as t}from"./button-CbdgUDvS.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{Zp as e}from"./messages-BLwDcEAr.js";import{i as t}from"./button-C0SPdqvs.js";var n=e();function r({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm`,e),"data-slot":`card`,...r})}function i({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6`,e),"data-slot":`card-header`,...r})}function a({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`font-semibold leading-none`,e),"data-slot":`card-title`,...r})}function o({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`text-muted-foreground text-sm`,e),"data-slot":`card-description`,...r})}function s({className:e,...r}){return(0,n.jsx)(`div`,{className:t(`px-6`,e),"data-slot":`card-content`,...r})}export{a,i,s as n,o as r,r as t};
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
import{r as e}from"./chunk-DECur_0Z.js";import{a as t,d as n,f as r}from"./auth-store-GibZbQPL.js";import{t as i}from"./react-CO2uhaBc.js";import{t as a}from"./createLucideIcon-Br0Bd5k2.js";var o=a(`pen-line`,[[`path`,{d:`M13 21h8`,key:`1jsn5i`}],[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}]]),s=e(i(),1),c=new n({chains:[],loading:!1});function l(e){c.setState(t=>({...t,chains:e}))}function u(e){c.setState(t=>({...t,loading:e}))}function d(){let e=r(c,e=>e.chains),n=r(c,e=>e.loading),i=t.useQuery(`get`,`/admin/api/v1/chains`);return(0,s.useEffect)(()=>{u(i.isLoading)},[i.isLoading]),(0,s.useEffect)(()=>{i.data?.data&&l(i.data.data)},[i.data?.data]),{chains:e,loading:n,refetch:i.refetch}}export{o as n,d as t};
|
import{r as e}from"./chunk-DECur_0Z.js";import{a as t,d as n,f as r}from"./auth-store-paR9oIR_.js";import{t as i}from"./react-CO2uhaBc.js";import{t as a}from"./createLucideIcon-Br0Bd5k2.js";var o=a(`pen-line`,[[`path`,{d:`M13 21h8`,key:`1jsn5i`}],[`path`,{d:`M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z`,key:`1a8usu`}]]),s=e(i(),1),c=new n({chains:[],loading:!1});function l(e){c.setState(t=>({...t,chains:e}))}function u(e){c.setState(t=>({...t,loading:e}))}function d(){let e=r(c,e=>e.chains),n=r(c,e=>e.loading),i=t.useQuery(`get`,`/admin/api/v1/chains`);return(0,s.useEffect)(()=>{u(i.isLoading)},[i.isLoading]),(0,s.useEffect)(()=>{i.data?.data&&l(i.data.data)},[i.data?.data]),{chains:e,loading:n,refetch:i.refetch}}export{o as n,d as t};
|
||||||
@@ -1 +1 @@
|
|||||||
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Pp as n}from"./messages-d1hRBHLP.js";import{t as r}from"./dist-CXfAGnWR.js";import{i,u as a}from"./button-CbdgUDvS.js";import{a as o,r as s,t as c}from"./dist-CIovDv3v.js";import{t as l}from"./dist-xVhEP_Vg.js";import{t as u}from"./dist-BixeH3nX.js";import{t as d}from"./dist-DuNl0ZwE.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};
|
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Zp as n}from"./messages-BLwDcEAr.js";import{t as r}from"./dist-XiAcd8rT.js";import{i,u as a}from"./button-C0SPdqvs.js";import{a as o,r as s,t as c}from"./dist-D343v8gR.js";import{t as l}from"./dist-C9d8iJDv.js";import{t as u}from"./dist-BixeH3nX.js";import{t as d}from"./dist-DJTK1B2D.js";import{t as f}from"./check-CHp0nSkR.js";var p=e(t(),1),m=n(),h=`Checkbox`,[g,_]=o(h),[v,y]=g(h);function b(e){let{__scopeCheckbox:t,checked:n,children:r,defaultChecked:i,disabled:a,form:o,name:s,onCheckedChange:l,required:u,value:d=`on`,internal_do_not_use_render:f}=e,[g,_]=c({prop:n,defaultProp:i??!1,onChange:l,caller:h}),[y,b]=p.useState(null),[x,S]=p.useState(null),C=p.useRef(!1),w=y?!!o||!!y.closest(`form`):!0,T={checked:g,disabled:a,setChecked:_,control:y,setControl:b,name:s,form:o,value:d,hasConsumerStoppedPropagationRef:C,required:u,defaultChecked:k(i)?!1:i,isFormControl:w,bubbleInput:x,setBubbleInput:S};return(0,m.jsx)(v,{scope:t,...T,children:O(f)?f(T):r})}var x=`CheckboxTrigger`,S=p.forwardRef(({__scopeCheckbox:e,onKeyDown:t,onClick:n,...i},o)=>{let{control:c,value:l,disabled:u,checked:d,required:f,setControl:h,setChecked:g,hasConsumerStoppedPropagationRef:_,isFormControl:v,bubbleInput:b}=y(x,e),S=a(o,h),C=p.useRef(d);return p.useEffect(()=>{let e=c?.form;if(e){let t=()=>g(C.current);return e.addEventListener(`reset`,t),()=>e.removeEventListener(`reset`,t)}},[c,g]),(0,m.jsx)(r.button,{type:`button`,role:`checkbox`,"aria-checked":k(d)?`mixed`:d,"aria-required":f,"data-state":A(d),"data-disabled":u?``:void 0,disabled:u,value:l,...i,ref:S,onKeyDown:s(t,e=>{e.key===`Enter`&&e.preventDefault()}),onClick:s(n,e=>{g(e=>k(e)?!0:!e),b&&v&&(_.current=e.isPropagationStopped(),_.current||e.stopPropagation())})})});S.displayName=x;var C=p.forwardRef((e,t)=>{let{__scopeCheckbox:n,name:r,checked:i,defaultChecked:a,required:o,disabled:s,value:c,onCheckedChange:l,form:u,...d}=e;return(0,m.jsx)(b,{__scopeCheckbox:n,checked:i,defaultChecked:a,disabled:s,required:o,onCheckedChange:l,name:r,form:u,value:c,internal_do_not_use_render:({isFormControl:e})=>(0,m.jsxs)(m.Fragment,{children:[(0,m.jsx)(S,{...d,ref:t,__scopeCheckbox:n}),e&&(0,m.jsx)(D,{__scopeCheckbox:n})]})})});C.displayName=h;var w=`CheckboxIndicator`,T=p.forwardRef((e,t)=>{let{__scopeCheckbox:n,forceMount:i,...a}=e,o=y(w,n);return(0,m.jsx)(l,{present:i||k(o.checked)||o.checked===!0,children:(0,m.jsx)(r.span,{"data-state":A(o.checked),"data-disabled":o.disabled?``:void 0,...a,ref:t,style:{pointerEvents:`none`,...e.style}})})});T.displayName=w;var E=`CheckboxBubbleInput`,D=p.forwardRef(({__scopeCheckbox:e,...t},n)=>{let{control:i,hasConsumerStoppedPropagationRef:o,checked:s,defaultChecked:c,required:l,disabled:f,name:h,value:g,form:_,bubbleInput:v,setBubbleInput:b}=y(E,e),x=a(n,b),S=u(s),C=d(i);p.useEffect(()=>{let e=v;if(!e)return;let t=window.HTMLInputElement.prototype,n=Object.getOwnPropertyDescriptor(t,`checked`).set,r=!o.current;if(S!==s&&n){let t=new Event(`click`,{bubbles:r});e.indeterminate=k(s),n.call(e,k(s)?!1:s),e.dispatchEvent(t)}},[v,S,s,o]);let w=p.useRef(k(s)?!1:s);return(0,m.jsx)(r.input,{type:`checkbox`,"aria-hidden":!0,defaultChecked:c??w.current,required:l,disabled:f,name:h,value:g,form:_,...t,tabIndex:-1,ref:x,style:{...t.style,...C,position:`absolute`,pointerEvents:`none`,opacity:0,margin:0,transform:`translateX(-100%)`}})});D.displayName=E;function O(e){return typeof e==`function`}function k(e){return e===`indeterminate`}function A(e){return k(e)?`indeterminate`:e?`checked`:`unchecked`}function j({className:e,...t}){return(0,m.jsx)(C,{className:i(`peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs outline-none transition-shadow focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:bg-input/30 dark:data-[state=checked]:bg-primary dark:aria-invalid:ring-destructive/40`,e),"data-slot":`checkbox`,...t,children:(0,m.jsx)(T,{className:`grid place-content-center text-current transition-none`,"data-slot":`checkbox-indicator`,children:(0,m.jsx)(f,{className:`size-3.5`})})})}export{j as t};
|
||||||
@@ -0,0 +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,payment_type:e.payment_type,status:e.status}}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 +0,0 @@
|
|||||||
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};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{Mp as e,Np as t,Pp as n,Zp as r}from"./messages-BLwDcEAr.js";import{t as i}from"./telescope-DU0wiTDw.js";import{i as a,o,r as s,s as c,t as l}from"./dialog-iqE0q8bN.js";var u=r();function d({open:r,onOpenChange:d}){return(0,u.jsx)(l,{onOpenChange:d,open:r,children:(0,u.jsx)(s,{className:`sm:max-w-sm`,children:(0,u.jsxs)(o,{className:`items-center text-center`,children:[(0,u.jsx)(i,{className:`mb-2 size-12 text-muted-foreground`}),(0,u.jsx)(c,{children:n()}),(0,u.jsxs)(a,{children:[t(),(0,u.jsx)(`br`,{}),(0,u.jsx)(`p`,{className:`mt-2 text-center`,children:e()})]})]})})})}export{d as t};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{Pp as e,_p as t,gp as n,hp as r}from"./messages-d1hRBHLP.js";import{t as i}from"./telescope-Eo9PEeOA.js";import{i as a,o,r as s,s as c,t as l}from"./dialog-SgRLLGoQ.js";var u=e();function d({open:e,onOpenChange:d}){return(0,u.jsx)(l,{onOpenChange:d,open:e,children:(0,u.jsx)(s,{className:`sm:max-w-sm`,children:(0,u.jsxs)(o,{className:`items-center text-center`,children:[(0,u.jsx)(i,{className:`mb-2 size-12 text-muted-foreground`}),(0,u.jsx)(c,{children:t()}),(0,u.jsxs)(a,{children:[n(),(0,u.jsx)(`br`,{}),(0,u.jsx)(`p`,{className:`mt-2 text-center`,children:r()})]})]})})})}export{d as t};
|
|
||||||
File diff suppressed because one or more lines are too long
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Op as n,Pp as r,kp as i}from"./messages-d1hRBHLP.js";import{c as a,i as o,t as s,u as c}from"./button-CbdgUDvS.js";import{a as l,r as u}from"./dist-CIovDv3v.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-BjDKUEMi.js";var b=e(t(),1),x=r(),S=`AlertDialog`,[C,te]=l(S,[y]),w=y(),T=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(g,{...r,...n,modal:!0})};T.displayName=S;var E=`AlertDialogTrigger`,D=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(f,{...i,...r,ref:t})});D.displayName=E;var O=`AlertDialogPortal`,k=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(d,{...r,...n})};k.displayName=O;var A=`AlertDialogOverlay`,j=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(p,{...i,...r,ref:t})});j.displayName=A;var M=`AlertDialogContent`,[N,P]=C(M),F=a(`AlertDialogContent`),I=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,children:r,...i}=e,a=w(n),o=b.useRef(null),s=c(t,o),l=b.useRef(null);return(0,x.jsx)(m,{contentName:M,titleName:L,docsSlug:`alert-dialog`,children:(0,x.jsx)(N,{scope:n,cancelRef:l,children:(0,x.jsxs)(h,{role:`alertdialog`,...a,...i,ref:s,onOpenAutoFocus:u(i.onOpenAutoFocus,e=>{e.preventDefault(),l.current?.focus({preventScroll:!0})}),onPointerDownOutside:e=>e.preventDefault(),onInteractOutside:e=>e.preventDefault(),children:[(0,x.jsx)(F,{children:r}),(0,x.jsx)(G,{contentRef:o})]})})})});I.displayName=M;var L=`AlertDialogTitle`,R=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(ee,{...i,...r,ref:t})});R.displayName=L;var z=`AlertDialogDescription`,B=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(_,{...i,...r,ref:t})});B.displayName=z;var V=`AlertDialogAction`,H=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(v,{...i,...r,ref:t})});H.displayName=V;var U=`AlertDialogCancel`,W=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,{cancelRef:i}=P(U,n),a=w(n),o=c(t,i);return(0,x.jsx)(v,{...a,...r,ref:o})});W.displayName=U;var G=({contentRef:e})=>{let t=`\`${M}\` requires a description for the component to be accessible for screen reader users.
|
import{r as e}from"./chunk-DECur_0Z.js";import{t}from"./react-CO2uhaBc.js";import{Gp as n,Kp as r,Zp as i}from"./messages-BLwDcEAr.js";import{c as a,i as o,t as s,u as c}from"./button-C0SPdqvs.js";import{a as l,r as u}from"./dist-D343v8gR.js";import{a as d,c as f,i as p,l as m,n as h,o as g,r as _,s as ee,t as v,u as y}from"./dist-BEGLjtzw.js";var b=e(t(),1),x=i(),S=`AlertDialog`,[C,te]=l(S,[y]),w=y(),T=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(g,{...r,...n,modal:!0})};T.displayName=S;var E=`AlertDialogTrigger`,D=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(f,{...i,...r,ref:t})});D.displayName=E;var O=`AlertDialogPortal`,k=e=>{let{__scopeAlertDialog:t,...n}=e,r=w(t);return(0,x.jsx)(d,{...r,...n})};k.displayName=O;var A=`AlertDialogOverlay`,j=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(p,{...i,...r,ref:t})});j.displayName=A;var M=`AlertDialogContent`,[N,P]=C(M),F=a(`AlertDialogContent`),I=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,children:r,...i}=e,a=w(n),o=b.useRef(null),s=c(t,o),l=b.useRef(null);return(0,x.jsx)(m,{contentName:M,titleName:L,docsSlug:`alert-dialog`,children:(0,x.jsx)(N,{scope:n,cancelRef:l,children:(0,x.jsxs)(h,{role:`alertdialog`,...a,...i,ref:s,onOpenAutoFocus:u(i.onOpenAutoFocus,e=>{e.preventDefault(),l.current?.focus({preventScroll:!0})}),onPointerDownOutside:e=>e.preventDefault(),onInteractOutside:e=>e.preventDefault(),children:[(0,x.jsx)(F,{children:r}),(0,x.jsx)(G,{contentRef:o})]})})})});I.displayName=M;var L=`AlertDialogTitle`,R=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(ee,{...i,...r,ref:t})});R.displayName=L;var z=`AlertDialogDescription`,B=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(_,{...i,...r,ref:t})});B.displayName=z;var V=`AlertDialogAction`,H=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,i=w(n);return(0,x.jsx)(v,{...i,...r,ref:t})});H.displayName=V;var U=`AlertDialogCancel`,W=b.forwardRef((e,t)=>{let{__scopeAlertDialog:n,...r}=e,{cancelRef:i}=P(U,n),a=w(n),o=c(t,i);return(0,x.jsx)(v,{...a,...r,ref:o})});W.displayName=U;var G=({contentRef:e})=>{let t=`\`${M}\` requires a description for the component to be accessible for screen reader users.
|
||||||
|
|
||||||
You can add a description to the \`${M}\` by passing a \`${z}\` component as a child, which also benefits sighted users by adding visible context to the dialog.
|
You can add a description to the \`${M}\` by passing a \`${z}\` component as a child, which also benefits sighted users by adding visible context to the dialog.
|
||||||
|
|
||||||
Alternatively, you can use your own component as a description by assigning it an \`id\` and passing the same value to the \`aria-describedby\` prop in \`${M}\`. If the description is confusing or duplicative for sighted users, you can use the \`@radix-ui/react-visually-hidden\` primitive as a wrapper around your description component.
|
Alternatively, you can use your own component as a description by assigning it an \`id\` and passing the same value to the \`aria-describedby\` prop in \`${M}\`. If the description is confusing or duplicative for sighted users, you can use the \`@radix-ui/react-visually-hidden\` primitive as a wrapper around your description component.
|
||||||
|
|
||||||
For more information, see https://radix-ui.com/primitives/docs/components/alert-dialog`;return b.useEffect(()=>{document.getElementById(e.current?.getAttribute(`aria-describedby`))||console.warn(t)},[t,e]),null},K=T,q=k,J=j,Y=I,X=W,Z=R,Q=B;function ne({...e}){return(0,x.jsx)(K,{"data-slot":`alert-dialog`,...e})}function re({...e}){return(0,x.jsx)(q,{"data-slot":`alert-dialog-portal`,...e})}function ie({className:e,...t}){return(0,x.jsx)(J,{className:o(`data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in`,e),"data-slot":`alert-dialog-overlay`,...t})}function ae({className:e,size:t=`default`,...n}){return(0,x.jsxs)(re,{children:[(0,x.jsx)(ie,{}),(0,x.jsx)(Y,{className:o(`group/alert-dialog-content data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=open]:animate-in data-[size=default]:sm:max-w-lg`,e),"data-size":t,"data-slot":`alert-dialog-content`,...n})]})}function oe({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]`,e),"data-slot":`alert-dialog-header`,...t})}function $({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end`,e),"data-slot":`alert-dialog-footer`,...t})}function se({className:e,...t}){return(0,x.jsx)(Z,{className:o(`font-semibold text-lg sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2`,e),"data-slot":`alert-dialog-title`,...t})}function ce({className:e,...t}){return(0,x.jsx)(Q,{className:o(`text-muted-foreground text-sm`,e),"data-slot":`alert-dialog-description`,...t})}function le({className:e,variant:t=`outline`,size:n=`default`,...r}){return(0,x.jsx)(s,{asChild:!0,size:n,variant:t,children:(0,x.jsx)(X,{className:o(e),"data-slot":`alert-dialog-cancel`,...r})})}function ue(e){let{title:t,desc:r,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:r})})]}),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??n()})]})]})})}export{ue as t};
|
For more information, see https://radix-ui.com/primitives/docs/components/alert-dialog`;return b.useEffect(()=>{document.getElementById(e.current?.getAttribute(`aria-describedby`))||console.warn(t)},[t,e]),null},K=T,q=k,J=j,Y=I,X=W,Z=R,Q=B;function ne({...e}){return(0,x.jsx)(K,{"data-slot":`alert-dialog`,...e})}function re({...e}){return(0,x.jsx)(q,{"data-slot":`alert-dialog-portal`,...e})}function ie({className:e,...t}){return(0,x.jsx)(J,{className:o(`data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=open]:animate-in`,e),"data-slot":`alert-dialog-overlay`,...t})}function ae({className:e,size:t=`default`,...n}){return(0,x.jsxs)(re,{children:[(0,x.jsx)(ie,{}),(0,x.jsx)(Y,{className:o(`group/alert-dialog-content data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[size=sm]:max-w-xs data-[state=closed]:animate-out data-[state=open]:animate-in data-[size=default]:sm:max-w-lg`,e),"data-size":t,"data-slot":`alert-dialog-content`,...n})]})}function oe({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]`,e),"data-slot":`alert-dialog-header`,...t})}function $({className:e,...t}){return(0,x.jsx)(`div`,{className:o(`flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end`,e),"data-slot":`alert-dialog-footer`,...t})}function se({className:e,...t}){return(0,x.jsx)(Z,{className:o(`font-semibold text-lg sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2`,e),"data-slot":`alert-dialog-title`,...t})}function ce({className:e,...t}){return(0,x.jsx)(Q,{className:o(`text-muted-foreground text-sm`,e),"data-slot":`alert-dialog-description`,...t})}function le({className:e,variant:t=`outline`,size:n=`default`,...r}){return(0,x.jsx)(s,{asChild:!0,size:n,variant:t,children:(0,x.jsx)(X,{className:o(e),"data-slot":`alert-dialog-cancel`,...r})})}function ue(e){let{title:t,desc:i,children:a,className:c,confirmText:l,cancelBtnText:u,destructive:d,isLoading:f,disabled:p=!1,form:m,handleConfirm:h,...g}=e;return(0,x.jsx)(ne,{...g,children:(0,x.jsxs)(ae,{className:o(c&&c),children:[(0,x.jsxs)(oe,{className:`text-start`,children:[(0,x.jsx)(se,{children:t}),(0,x.jsx)(ce,{asChild:!0,children:(0,x.jsx)(`div`,{children:i})})]}),a,(0,x.jsxs)($,{children:[(0,x.jsx)(le,{disabled:f,children:u??r()}),(0,x.jsx)(s,{disabled:p||f,form:m,onClick:h,type:m?`submit`:`button`,variant:d?`destructive`:`default`,children:l??n()})]})]})})}export{ue as t};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user