mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
Compare commits
9 Commits
54b83f8b16
...
v1.0.7
| Author | SHA1 | Date | |
|---|---|---|---|
| 75dfeced01 | |||
| bfdf453e3c | |||
| 55cc6a9c61 | |||
| 3ab3659fe4 | |||
| 04c2b9b0b6 | |||
| 9ac9894dba | |||
| 9b397bd45e | |||
| 225cae2c24 | |||
| 08d409d5eb |
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package admin
|
package admin
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,6 +32,49 @@ type OrderStatsResponse struct {
|
|||||||
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
|
ExpiredUnpaidCount int64 `json:"expired_unpaid_count" example:"10"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RpcStatsDashboardResponse is the RPC runtime stats payload.
|
||||||
|
type RpcStatsDashboardResponse struct {
|
||||||
|
GeneratedAt string `json:"generated_at" example:"2026-06-09T12:00:00+08:00"`
|
||||||
|
Summary RpcStatsSummaryResponse `json:"summary"`
|
||||||
|
Chains []RpcStatsChainResponse `json:"chains"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RpcStatsSummaryResponse is the overall RPC runtime summary.
|
||||||
|
type RpcStatsSummaryResponse struct {
|
||||||
|
TotalChains int `json:"total_chains" example:"6"`
|
||||||
|
EnabledChains int `json:"enabled_chains" example:"5"`
|
||||||
|
ActiveChains int `json:"active_chains" example:"4"`
|
||||||
|
OkChains int `json:"ok_chains" example:"3"`
|
||||||
|
WarningChains int `json:"warning_chains" example:"1"`
|
||||||
|
DownChains int `json:"down_chains" example:"0"`
|
||||||
|
UnknownChains int `json:"unknown_chains" example:"1"`
|
||||||
|
DisabledChains int `json:"disabled_chains" example:"1"`
|
||||||
|
SuccessCount int64 `json:"success_count" example:"123"`
|
||||||
|
FailureCount int64 `json:"failure_count" example:"2"`
|
||||||
|
SuccessRate float64 `json:"success_rate" example:"0.984"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RpcStatsChainResponse is one chain's in-process RPC runtime stats.
|
||||||
|
type RpcStatsChainResponse struct {
|
||||||
|
Network string `json:"network" example:"tron"`
|
||||||
|
DisplayName string `json:"display_name" example:"TRON"`
|
||||||
|
Enabled bool `json:"enabled" example:"true"`
|
||||||
|
HasData bool `json:"has_data" example:"true"`
|
||||||
|
Status string `json:"status" example:"ok" enums:"ok,warning,down,unknown,disabled"`
|
||||||
|
SuccessCount int64 `json:"success_count" example:"123"`
|
||||||
|
FailureCount int64 `json:"failure_count" example:"2"`
|
||||||
|
SuccessRate float64 `json:"success_rate" example:"0.984"`
|
||||||
|
LatestBlockHeight int64 `json:"latest_block_height" example:"64532100"`
|
||||||
|
LastSyncAt string `json:"last_sync_at" example:"2026-06-09T12:00:00+08:00"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcStatsStreamEnvelope struct {
|
||||||
|
StatusCode int `json:"status_code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Data RpcStatsDashboardResponse `json:"data"`
|
||||||
|
RequestID string `json:"request_id"`
|
||||||
|
}
|
||||||
|
|
||||||
// RpcHealthCheckResponse is the response for a health check probe.
|
// RpcHealthCheckResponse is the response for a health check probe.
|
||||||
type RpcHealthCheckResponse struct {
|
type RpcHealthCheckResponse struct {
|
||||||
// 健康状态 unknown=未知 ok=正常 down=异常
|
// 健康状态 unknown=未知 ok=正常 down=异常
|
||||||
@@ -230,6 +276,174 @@ func (c *BaseAdminController) OrderStats(ctx echo.Context) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RpcStats returns process-local RPC runtime counters grouped by chain.
|
||||||
|
// Values reset on process restart and are not persisted to the database.
|
||||||
|
// @Summary RPC runtime statistics
|
||||||
|
// @Description Streams in-process RPC success/failure counts, success rate, latest block height and last sync time by chain over one authenticated SSE connection.
|
||||||
|
// @Tags Admin Dashboard
|
||||||
|
// @Security AdminJWT
|
||||||
|
// @Produce text/event-stream
|
||||||
|
// @Success 200 {object} response.ApiResponse{data=admin.RpcStatsDashboardResponse}
|
||||||
|
// @Failure 400 {object} response.ApiResponse
|
||||||
|
// @Router /admin/api/v1/dashboard/rpc-stats [get]
|
||||||
|
func (c *BaseAdminController) RpcStats(ctx echo.Context) error {
|
||||||
|
return c.streamRpcStats(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildRpcStatsResponse() (RpcStatsDashboardResponse, error) {
|
||||||
|
chains, err := data.ListChains()
|
||||||
|
if err != nil {
|
||||||
|
return RpcStatsDashboardResponse{}, err
|
||||||
|
}
|
||||||
|
stats := data.SnapshotRpcRuntimeStats()
|
||||||
|
now := time.Now()
|
||||||
|
resp := RpcStatsDashboardResponse{
|
||||||
|
GeneratedAt: now.Format(time.RFC3339),
|
||||||
|
Chains: make([]RpcStatsChainResponse, 0, len(chains)),
|
||||||
|
}
|
||||||
|
for _, chain := range chains {
|
||||||
|
network := strings.ToLower(strings.TrimSpace(chain.Network))
|
||||||
|
stat := stats[network]
|
||||||
|
successRate := rpcStatsSuccessRate(stat.SuccessCount, stat.FailureCount)
|
||||||
|
hasData := stat.SuccessCount+stat.FailureCount > 0
|
||||||
|
status := rpcStatsStatus(chain.Enabled, stat.SuccessCount, stat.FailureCount)
|
||||||
|
lastSyncAt := ""
|
||||||
|
if !stat.LastSyncAt.IsZero() {
|
||||||
|
lastSyncAt = stat.LastSyncAt.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.Summary.TotalChains++
|
||||||
|
if chain.Enabled {
|
||||||
|
resp.Summary.EnabledChains++
|
||||||
|
}
|
||||||
|
if hasData {
|
||||||
|
resp.Summary.ActiveChains++
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case "ok":
|
||||||
|
resp.Summary.OkChains++
|
||||||
|
case "warning":
|
||||||
|
resp.Summary.WarningChains++
|
||||||
|
case "down":
|
||||||
|
resp.Summary.DownChains++
|
||||||
|
case "disabled":
|
||||||
|
resp.Summary.DisabledChains++
|
||||||
|
default:
|
||||||
|
resp.Summary.UnknownChains++
|
||||||
|
}
|
||||||
|
resp.Summary.SuccessCount += stat.SuccessCount
|
||||||
|
resp.Summary.FailureCount += stat.FailureCount
|
||||||
|
|
||||||
|
resp.Chains = append(resp.Chains, RpcStatsChainResponse{
|
||||||
|
Network: chain.Network,
|
||||||
|
DisplayName: rpcStatsDisplayName(chain.Network, chain.DisplayName),
|
||||||
|
Enabled: chain.Enabled,
|
||||||
|
HasData: hasData,
|
||||||
|
Status: status,
|
||||||
|
SuccessCount: stat.SuccessCount,
|
||||||
|
FailureCount: stat.FailureCount,
|
||||||
|
SuccessRate: successRate,
|
||||||
|
LatestBlockHeight: stat.LatestBlockHeight,
|
||||||
|
LastSyncAt: lastSyncAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
resp.Summary.SuccessRate = rpcStatsSuccessRate(resp.Summary.SuccessCount, resp.Summary.FailureCount)
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *BaseAdminController) streamRpcStats(ctx echo.Context) error {
|
||||||
|
initial, err := buildRpcStatsResponse()
|
||||||
|
if err != nil {
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := ctx.Response()
|
||||||
|
resp.Header().Set(echo.HeaderContentType, "text/event-stream; charset=utf-8")
|
||||||
|
resp.Header().Set("Cache-Control", "no-cache")
|
||||||
|
resp.Header().Set("Connection", "keep-alive")
|
||||||
|
resp.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
flusher, ok := resp.Writer.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "streaming unsupported")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.WriteHeader(http.StatusOK)
|
||||||
|
if _, err := fmt.Fprint(resp.Writer, "retry: 5000\n\n"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeRpcStatsSSE(ctx, flusher, initial); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ticker := time.NewTicker(5 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Request().Context().Done():
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
payload, err := buildRpcStatsResponse()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeRpcStatsSSE(ctx, flusher, payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeRpcStatsSSE(ctx echo.Context, flusher http.Flusher, payload RpcStatsDashboardResponse) error {
|
||||||
|
envelope := rpcStatsStreamEnvelope{
|
||||||
|
StatusCode: http.StatusOK,
|
||||||
|
Message: "success",
|
||||||
|
Data: payload,
|
||||||
|
RequestID: ctx.Request().Header.Get(echo.HeaderXRequestID),
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(envelope)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(ctx.Response().Writer, "event: rpc_stats\ndata: %s\n\n", b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcStatsDisplayName(network string, displayName string) string {
|
||||||
|
if name := strings.TrimSpace(displayName); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(network)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcStatsSuccessRate(successCount, failureCount int64) float64 {
|
||||||
|
total := successCount + failureCount
|
||||||
|
if total <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(successCount) / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcStatsStatus(enabled bool, successCount, failureCount int64) string {
|
||||||
|
if !enabled {
|
||||||
|
return "disabled"
|
||||||
|
}
|
||||||
|
if successCount+failureCount == 0 {
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
if successCount == 0 {
|
||||||
|
return "down"
|
||||||
|
}
|
||||||
|
if failureCount > 0 {
|
||||||
|
return "warning"
|
||||||
|
}
|
||||||
|
return "ok"
|
||||||
|
}
|
||||||
|
|
||||||
// RecentOrders returns the last N orders for the dashboard list card.
|
// RecentOrders returns the last N orders for the dashboard list card.
|
||||||
// @Summary Recent orders
|
// @Summary Recent orders
|
||||||
// @Description Returns the last N orders for the dashboard
|
// @Description Returns the last N orders for the dashboard
|
||||||
|
|||||||
@@ -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
|
||||||
|
// 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)
|
_ = 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{}{
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RpcRuntimeStatsSnapshot is an in-process snapshot of RPC activity for one
|
||||||
|
// chain. It is intentionally not persisted; restarting the process resets it.
|
||||||
|
type RpcRuntimeStatsSnapshot struct {
|
||||||
|
Network string
|
||||||
|
SuccessCount int64
|
||||||
|
FailureCount int64
|
||||||
|
LatestBlockHeight int64
|
||||||
|
LastSyncAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type rpcRuntimeStatsCounter struct {
|
||||||
|
successCount int64
|
||||||
|
failureCount int64
|
||||||
|
latestBlockHeight int64
|
||||||
|
lastSyncAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var gRpcRuntimeStats = struct {
|
||||||
|
sync.RWMutex
|
||||||
|
byNetwork map[string]*rpcRuntimeStatsCounter
|
||||||
|
}{
|
||||||
|
byNetwork: make(map[string]*rpcRuntimeStatsCounter),
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeRpcStatsNetwork(network string) string {
|
||||||
|
return strings.ToLower(strings.TrimSpace(network))
|
||||||
|
}
|
||||||
|
|
||||||
|
func rpcStatsCounterLocked(network string) *rpcRuntimeStatsCounter {
|
||||||
|
counter := gRpcRuntimeStats.byNetwork[network]
|
||||||
|
if counter == nil {
|
||||||
|
counter = &rpcRuntimeStatsCounter{}
|
||||||
|
gRpcRuntimeStats.byNetwork[network] = counter
|
||||||
|
}
|
||||||
|
return counter
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordRpcSuccess records one successful application-level RPC operation.
|
||||||
|
func RecordRpcSuccess(network string) {
|
||||||
|
network = normalizeRpcStatsNetwork(network)
|
||||||
|
if network == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gRpcRuntimeStats.Lock()
|
||||||
|
defer gRpcRuntimeStats.Unlock()
|
||||||
|
counter := rpcStatsCounterLocked(network)
|
||||||
|
counter.successCount++
|
||||||
|
counter.lastSyncAt = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordRpcFailure records one failed application-level RPC operation.
|
||||||
|
func RecordRpcFailure(network string) {
|
||||||
|
network = normalizeRpcStatsNetwork(network)
|
||||||
|
if network == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gRpcRuntimeStats.Lock()
|
||||||
|
defer gRpcRuntimeStats.Unlock()
|
||||||
|
counter := rpcStatsCounterLocked(network)
|
||||||
|
counter.failureCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordRpcBlockHeight records the latest observed chain height.
|
||||||
|
func RecordRpcBlockHeight(network string, height int64) {
|
||||||
|
network = normalizeRpcStatsNetwork(network)
|
||||||
|
if network == "" || height <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
gRpcRuntimeStats.Lock()
|
||||||
|
defer gRpcRuntimeStats.Unlock()
|
||||||
|
counter := rpcStatsCounterLocked(network)
|
||||||
|
counter.lastSyncAt = time.Now()
|
||||||
|
if height > counter.latestBlockHeight {
|
||||||
|
counter.latestBlockHeight = height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotRpcRuntimeStats returns a copy of the current per-chain counters.
|
||||||
|
func SnapshotRpcRuntimeStats() map[string]RpcRuntimeStatsSnapshot {
|
||||||
|
gRpcRuntimeStats.RLock()
|
||||||
|
defer gRpcRuntimeStats.RUnlock()
|
||||||
|
out := make(map[string]RpcRuntimeStatsSnapshot, len(gRpcRuntimeStats.byNetwork))
|
||||||
|
for network, counter := range gRpcRuntimeStats.byNetwork {
|
||||||
|
out[network] = RpcRuntimeStatsSnapshot{
|
||||||
|
Network: network,
|
||||||
|
SuccessCount: counter.successCount,
|
||||||
|
FailureCount: counter.failureCount,
|
||||||
|
LatestBlockHeight: counter.latestBlockHeight,
|
||||||
|
LastSyncAt: counter.lastSyncAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResetRpcRuntimeStatsForTest clears process-local stats for isolated tests.
|
||||||
|
func ResetRpcRuntimeStatsForTest() {
|
||||||
|
gRpcRuntimeStats.Lock()
|
||||||
|
defer gRpcRuntimeStats.Unlock()
|
||||||
|
gRpcRuntimeStats.byNetwork = make(map[string]*rpcRuntimeStatsCounter)
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRpcRuntimeStatsRecordsCountsAndLatestHeight(t *testing.T) {
|
||||||
|
ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
RecordRpcSuccess(" TRON ")
|
||||||
|
RecordRpcSuccess("tron")
|
||||||
|
RecordRpcFailure("TRON")
|
||||||
|
RecordRpcBlockHeight("Tron", 100)
|
||||||
|
RecordRpcBlockHeight("tron", 90)
|
||||||
|
|
||||||
|
stats := SnapshotRpcRuntimeStats()
|
||||||
|
got, ok := stats["tron"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("missing tron stats: %#v", stats)
|
||||||
|
}
|
||||||
|
if got.Network != "tron" {
|
||||||
|
t.Fatalf("network = %q, want tron", got.Network)
|
||||||
|
}
|
||||||
|
if got.SuccessCount != 2 {
|
||||||
|
t.Fatalf("success count = %d, want 2", got.SuccessCount)
|
||||||
|
}
|
||||||
|
if got.FailureCount != 1 {
|
||||||
|
t.Fatalf("failure count = %d, want 1", got.FailureCount)
|
||||||
|
}
|
||||||
|
if got.LatestBlockHeight != 100 {
|
||||||
|
t.Fatalf("latest block height = %d, want 100", got.LatestBlockHeight)
|
||||||
|
}
|
||||||
|
if got.LastSyncAt.Before(start) {
|
||||||
|
t.Fatalf("last sync at = %s, want after %s", got.LastSyncAt, start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRpcRuntimeStatsIgnoresEmptyNetworkAndInvalidHeight(t *testing.T) {
|
||||||
|
ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
RecordRpcSuccess("")
|
||||||
|
RecordRpcFailure(" ")
|
||||||
|
RecordRpcBlockHeight("tron", 0)
|
||||||
|
|
||||||
|
stats := SnapshotRpcRuntimeStats()
|
||||||
|
if len(stats) != 0 {
|
||||||
|
t.Fatalf("stats = %#v, want empty", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRpcRuntimeStatsBlockHeightUpdatesLastSyncAt(t *testing.T) {
|
||||||
|
ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
RecordRpcBlockHeight("ethereum", 123)
|
||||||
|
|
||||||
|
stats := SnapshotRpcRuntimeStats()
|
||||||
|
got := stats["ethereum"]
|
||||||
|
if got.SuccessCount != 0 {
|
||||||
|
t.Fatalf("success count = %d, want 0", got.SuccessCount)
|
||||||
|
}
|
||||||
|
if got.LastSyncAt.Before(start) {
|
||||||
|
t.Fatalf("last sync at = %s, want after %s", got.LastSyncAt, start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetRpcRuntimeStatsForTest(t *testing.T) {
|
||||||
|
ResetRpcRuntimeStatsForTest()
|
||||||
|
RecordRpcSuccess("tron")
|
||||||
|
ResetRpcRuntimeStatsForTest()
|
||||||
|
|
||||||
|
stats := SnapshotRpcRuntimeStats()
|
||||||
|
if len(stats) != 0 {
|
||||||
|
t.Fatalf("stats = %#v, want empty after reset", stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,21 @@ 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,8 +46,8 @@ 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月卡"`
|
||||||
@@ -45,12 +55,14 @@ type Orders struct {
|
|||||||
// 回调确认状态 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 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"`
|
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,13 +6,17 @@ 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 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"`
|
PaymentType string `json:"payment_type" form:"payment_type" example:"Epay"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"` // 收银台地址
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,24 @@ 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 占位订单为空
|
||||||
|
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"` // 过期时间 时间戳
|
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,10 +277,12 @@ 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 hasTransactionLock(&sub) {
|
||||||
if err = data.UnLockTransaction(sub.Network, sub.ReceiveAddress, sub.Token, sub.ActualAmount); err != nil {
|
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)
|
log.Sugar.Warnf("[order] unlock sub-order transaction failed, trade_id=%s, err=%v", sub.TradeId, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,9 +331,11 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Release parent's own wallet lock
|
// Release parent's own wallet lock
|
||||||
|
if hasTransactionLock(parent) {
|
||||||
if err = data.UnLockTransaction(parent.Network, parent.ReceiveAddress, parent.Token, parent.ActualAmount); err != nil {
|
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)
|
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Release sibling locks after their status transitions commit.
|
// Release sibling locks after their status transitions commit.
|
||||||
for _, sib := range siblings {
|
for _, sib := range siblings {
|
||||||
@@ -274,10 +344,12 @@ 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 hasTransactionLock(&sib) {
|
||||||
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
|
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)
|
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -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{
|
||||||
|
TradeId: bscTradeID,
|
||||||
|
OrderId: bscTradeID,
|
||||||
|
ParentTradeId: parentResp.TradeId,
|
||||||
|
Amount: parentResp.Amount,
|
||||||
|
Currency: parentResp.Currency,
|
||||||
|
ActualAmount: subEthResp.ActualAmount,
|
||||||
|
ReceiveAddress: bscAddress,
|
||||||
|
Token: "USDT",
|
||||||
Network: mdb.NetworkBsc,
|
Network: mdb.NetworkBsc,
|
||||||
})
|
Status: mdb.StatusWaitPay,
|
||||||
if err != nil {
|
IsSelected: true,
|
||||||
t.Fatalf("switch to bsc sub-order: %v", err)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,7 +325,15 @@ func resolveSolanaRpcNode(excludeIDs ...uint64) (*mdb.RpcNode, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
||||||
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
func SolRetryClient(method string, params []interface{}) (body []byte, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkSolana)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkSolana)
|
||||||
|
}()
|
||||||
|
|
||||||
tried := make([]uint64, 0, 3)
|
tried := make([]uint64, 0, 3)
|
||||||
var lastErr error
|
var lastErr error
|
||||||
for attempts := 0; attempts < 3; attempts++ {
|
for attempts := 0; attempts < 3; attempts++ {
|
||||||
@@ -443,6 +451,32 @@ func SolGetSignaturesForAddress(address string, limit int, untilSig string, befo
|
|||||||
return bodyData, nil
|
return bodyData, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SolGetBlockHeight() (height int64, err error) {
|
||||||
|
defer func() {
|
||||||
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkSolana)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkSolana)
|
||||||
|
}()
|
||||||
|
|
||||||
|
rpcURL, err := resolveSolanaRpcURL()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
bodyData, err := solRetryClientWithURL(rpcURL, "getBlockHeight", []interface{}{
|
||||||
|
map[string]interface{}{"commitment": "finalized"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
height = gjson.GetBytes(bodyData, "result").Int()
|
||||||
|
if height <= 0 {
|
||||||
|
return 0, fmt.Errorf("unexpected getBlockHeight response: %s", string(bodyData))
|
||||||
|
}
|
||||||
|
return height, nil
|
||||||
|
}
|
||||||
|
|
||||||
func SolGetTransaction(sig string) ([]byte, error) {
|
func SolGetTransaction(sig string) ([]byte, error) {
|
||||||
txData, err := SolRetryClient("getTransaction", []interface{}{
|
txData, err := SolRetryClient("getTransaction", []interface{}{
|
||||||
sig,
|
sig,
|
||||||
|
|||||||
@@ -102,6 +102,53 @@ func TestResolveSolanaRpcURLUsesGeneralWhenManualVerifyExists(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSolGetBlockHeightDoesNotAffectRpcNodeFailover(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
data.ResetRpcFailoverForTest()
|
||||||
|
data.ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||||
|
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
oldRetryCount := solRPCRetryCount
|
||||||
|
solRPCRetryCount = 0
|
||||||
|
t.Cleanup(func() {
|
||||||
|
solRPCRetryCount = oldRetryCount
|
||||||
|
})
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "temporary", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
node := &mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkSolana,
|
||||||
|
Url: server.URL,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Weight: 1,
|
||||||
|
Enabled: true,
|
||||||
|
Purpose: mdb.RpcNodePurposeGeneral,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||||
|
t.Fatalf("seed solana rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < data.RpcFailoverThreshold+1; i++ {
|
||||||
|
if _, err := SolGetBlockHeight(); err == nil {
|
||||||
|
t.Fatalf("SolGetBlockHeight attempt %d unexpectedly succeeded", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.IsRpcNodeCoolingDown(node.ID) {
|
||||||
|
t.Fatal("SolGetBlockHeight failures should not affect rpc_node failover cooldown")
|
||||||
|
}
|
||||||
|
stats := data.SnapshotRpcRuntimeStats()
|
||||||
|
if got := stats[mdb.NetworkSolana].FailureCount; got != int64(data.RpcFailoverThreshold+1) {
|
||||||
|
t.Fatalf("solana runtime failure_count = %d, want %d", got, data.RpcFailoverThreshold+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSolRetryClientSwitchesAfterFailureThreshold(t *testing.T) {
|
func TestSolRetryClientSwitchesAfterFailureThreshold(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package route
|
package route
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -16,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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -230,6 +233,7 @@ func TestAdminProtectedRoute_NoToken(t *testing.T) {
|
|||||||
"/admin/api/v1/wallets",
|
"/admin/api/v1/wallets",
|
||||||
"/admin/api/v1/orders",
|
"/admin/api/v1/orders",
|
||||||
"/admin/api/v1/dashboard/overview",
|
"/admin/api/v1/dashboard/overview",
|
||||||
|
"/admin/api/v1/dashboard/rpc-stats",
|
||||||
"/admin/api/v1/settings",
|
"/admin/api/v1/settings",
|
||||||
"/admin/api/v1/rpc-nodes",
|
"/admin/api/v1/rpc-nodes",
|
||||||
"/admin/api/v1/notification-channels",
|
"/admin/api/v1/notification-channels",
|
||||||
@@ -759,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)
|
||||||
@@ -971,7 +1003,7 @@ func TestAdminOrders_ResendCallbackRequeuesPaidOrder(t *testing.T) {
|
|||||||
|
|
||||||
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200.
|
// TestAdminDashboard_AllRoutes verifies ordinary JSON dashboard endpoints return 200.
|
||||||
func TestAdminDashboard_AllRoutes(t *testing.T) {
|
func TestAdminDashboard_AllRoutes(t *testing.T) {
|
||||||
e, token := setupAdminTestEnv(t)
|
e, token := setupAdminTestEnv(t)
|
||||||
routes := []string{
|
routes := []string{
|
||||||
@@ -988,6 +1020,148 @@ func TestAdminDashboard_AllRoutes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminDashboardRpcStatsSSE(t *testing.T) {
|
||||||
|
e, token := setupAdminTestEnv(t)
|
||||||
|
data.RecordRpcSuccess("TRON")
|
||||||
|
data.RecordRpcSuccess("tron")
|
||||||
|
data.RecordRpcFailure(" tron ")
|
||||||
|
data.RecordRpcBlockHeight("tron", 64532100)
|
||||||
|
|
||||||
|
server := httptest.NewServer(e)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
reqCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, server.URL+"/admin/api/v1/dashboard/rpc-stats", nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new request: %v", err)
|
||||||
|
}
|
||||||
|
req.Header.Set(echo.HeaderAuthorization, "Bearer "+token)
|
||||||
|
req.Header.Set(echo.HeaderAccept, "text/event-stream")
|
||||||
|
|
||||||
|
resp, err := server.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sse request: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("sse status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if ct := resp.Header.Get(echo.HeaderContentType); !strings.Contains(ct, "text/event-stream") {
|
||||||
|
t.Fatalf("content-type = %q, want text/event-stream", ct)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
var eventName string
|
||||||
|
var dataLine string
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read sse line: %v", err)
|
||||||
|
}
|
||||||
|
line = strings.TrimRight(line, "\r\n")
|
||||||
|
if strings.HasPrefix(line, "event: ") {
|
||||||
|
eventName = strings.TrimPrefix(line, "event: ")
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "data: ") {
|
||||||
|
dataLine = strings.TrimPrefix(line, "data: ")
|
||||||
|
}
|
||||||
|
if line == "" && dataLine != "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if eventName != "rpc_stats" {
|
||||||
|
t.Fatalf("event = %q, want rpc_stats", eventName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var envelope map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(dataLine), &envelope); err != nil {
|
||||||
|
t.Fatalf("unmarshal sse data: %v data=%s", err, dataLine)
|
||||||
|
}
|
||||||
|
if got, _ := envelope["status_code"].(float64); got != 200 {
|
||||||
|
t.Fatalf("sse status_code = %v, want 200", got)
|
||||||
|
}
|
||||||
|
dataObj, ok := envelope["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("sse data = %#v, want object", envelope["data"])
|
||||||
|
}
|
||||||
|
rows, ok := dataObj["chains"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("sse chains = %#v, want array", dataObj["chains"])
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, ok := dataObj["summary"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("summary = %#v, want object", dataObj["summary"])
|
||||||
|
}
|
||||||
|
if got, _ := summary["success_count"].(float64); got != 2 {
|
||||||
|
t.Fatalf("summary success_count = %v, want 2", got)
|
||||||
|
}
|
||||||
|
if got, _ := summary["failure_count"].(float64); got != 1 {
|
||||||
|
t.Fatalf("summary failure_count = %v, want 1", got)
|
||||||
|
}
|
||||||
|
if got, _ := summary["success_rate"].(float64); got < 0.666 || got > 0.667 {
|
||||||
|
t.Fatalf("summary success_rate = %v, want about 0.6667", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tron map[string]interface{}
|
||||||
|
var solana map[string]interface{}
|
||||||
|
for _, row := range rows {
|
||||||
|
item, _ := row.(map[string]interface{})
|
||||||
|
switch item["network"] {
|
||||||
|
case mdb.NetworkTron:
|
||||||
|
tron = item
|
||||||
|
case mdb.NetworkSolana:
|
||||||
|
solana = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tron == nil {
|
||||||
|
t.Fatalf("missing tron row in %#v", rows)
|
||||||
|
}
|
||||||
|
if got, _ := tron["success_count"].(float64); got != 2 {
|
||||||
|
t.Fatalf("tron success_count = %v, want 2", got)
|
||||||
|
}
|
||||||
|
if got, _ := tron["failure_count"].(float64); got != 1 {
|
||||||
|
t.Fatalf("tron failure_count = %v, want 1", got)
|
||||||
|
}
|
||||||
|
if got, _ := tron["success_rate"].(float64); got < 0.666 || got > 0.667 {
|
||||||
|
t.Fatalf("tron success_rate = %v, want about 0.6667", got)
|
||||||
|
}
|
||||||
|
if got, _ := tron["latest_block_height"].(float64); got != 64532100 {
|
||||||
|
t.Fatalf("tron latest_block_height = %v, want 64532100", got)
|
||||||
|
}
|
||||||
|
if got, _ := tron["last_sync_at"].(string); got == "" {
|
||||||
|
t.Fatalf("tron last_sync_at is empty: %#v", tron)
|
||||||
|
}
|
||||||
|
if got, _ := tron["display_name"].(string); got != "TRON" {
|
||||||
|
t.Fatalf("tron display_name = %q, want TRON", got)
|
||||||
|
}
|
||||||
|
if got, _ := tron["status"].(string); got != "warning" {
|
||||||
|
t.Fatalf("tron status = %q, want warning", got)
|
||||||
|
}
|
||||||
|
if solana == nil {
|
||||||
|
t.Fatalf("missing solana row in %#v", rows)
|
||||||
|
}
|
||||||
|
if got, _ := solana["success_count"].(float64); got != 0 {
|
||||||
|
t.Fatalf("solana success_count = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got, _ := solana["failure_count"].(float64); got != 0 {
|
||||||
|
t.Fatalf("solana failure_count = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got, _ := solana["success_rate"].(float64); got != 0 {
|
||||||
|
t.Fatalf("solana success_rate = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got, _ := solana["last_sync_at"].(string); got != "" {
|
||||||
|
t.Fatalf("solana last_sync_at = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got, _ := solana["latest_block_height"].(float64); got != 0 {
|
||||||
|
t.Fatalf("solana latest_block_height = %v, want 0", got)
|
||||||
|
}
|
||||||
|
if got, _ := solana["status"].(string); got != "unknown" {
|
||||||
|
t.Fatalf("solana status = %q, want unknown", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestAdminDashboard_RangeStatsIncludeHistoricalOrders(t *testing.T) {
|
func TestAdminDashboard_RangeStatsIncludeHistoricalOrders(t *testing.T) {
|
||||||
e, token := setupAdminTestEnv(t)
|
e, token := setupAdminTestEnv(t)
|
||||||
yesterday := time.Now().AddDate(0, 0, -1)
|
yesterday := time.Now().AddDate(0, 0, -1)
|
||||||
@@ -1296,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)
|
||||||
|
|||||||
+23
-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,
|
||||||
@@ -269,6 +287,7 @@ func registerAdminRoutes(e *echo.Echo) {
|
|||||||
authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend)
|
authed.GET("/dashboard/asset-trend", admin.Ctrl.AssetTrend)
|
||||||
authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend)
|
authed.GET("/dashboard/revenue-trend", admin.Ctrl.RevenueTrend)
|
||||||
authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats)
|
authed.GET("/dashboard/order-stats", admin.Ctrl.OrderStats)
|
||||||
|
authed.GET("/dashboard/rpc-stats", admin.Ctrl.RpcStats)
|
||||||
authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders)
|
authed.GET("/dashboard/recent-orders", admin.Ctrl.RecentOrders)
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
|
|||||||
+657
-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)
|
||||||
@@ -77,6 +83,7 @@ func setupTestEnv(t *testing.T) *echo.Echo {
|
|||||||
|
|
||||||
// reset the settings cache so stale entries from a prior test don't leak
|
// reset the settings cache so stale entries from a prior test don't leak
|
||||||
_ = data.ReloadSettings()
|
_ = data.ReloadSettings()
|
||||||
|
data.ResetRpcRuntimeStatsForTest()
|
||||||
config.SettingsGetString = func(key string) string {
|
config.SettingsGetString = func(key string) string {
|
||||||
return data.GetSettingString(key, "")
|
return data.GetSettingString(key, "")
|
||||||
}
|
}
|
||||||
@@ -412,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 {
|
||||||
@@ -542,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)
|
||||||
}
|
}
|
||||||
@@ -658,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 {
|
||||||
@@ -1003,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"},
|
||||||
@@ -1023,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) {
|
||||||
@@ -1392,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")
|
||||||
@@ -1683,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()
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func runBscListener(contracts []common.Address) {
|
|||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener(ctx, "[BSC-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
runEvmWsLogListener(ctx, mdb.NetworkBsc, "[BSC-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,9 +98,11 @@ func runBscListener(contracts []common.Address) {
|
|||||||
var blockTsMs int64
|
var blockTsMs int64
|
||||||
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkBsc)
|
||||||
log.Sugar.Warnf("[BSC-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
log.Sugar.Warnf("[BSC-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
blockTsMs = time.Now().UnixMilli()
|
blockTsMs = time.Now().UnixMilli()
|
||||||
} else {
|
} else {
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkBsc)
|
||||||
blockTsMs = int64(header.Time) * 1000
|
blockTsMs = int64(header.Time) * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
package task
|
package task
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math/big"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
epLog "github.com/GMWalletApp/epusdt/util/log"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
epLog.Sugar = zap.NewNop().Sugar()
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveChainWsURLRequiresEnabledRpcNode(t *testing.T) {
|
func TestResolveChainWsURLRequiresEnabledRpcNode(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -116,6 +125,70 @@ func TestResolveChainWsNodeSkipsCoolingNode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEvmWsNodeFailureRecordsRuntimeStats(t *testing.T) {
|
||||||
|
data.ResetRpcFailoverForTest()
|
||||||
|
data.ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(data.ResetRpcFailoverForTest)
|
||||||
|
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
recordEvmWsNodeFailure("[TEST]", mdb.NetworkEthereum, mdb.RpcNode{
|
||||||
|
BaseModel: mdb.BaseModel{ID: 123},
|
||||||
|
Url: "wss://ethereum.example.com",
|
||||||
|
Type: mdb.RpcNodeTypeWs,
|
||||||
|
}, "dial")
|
||||||
|
|
||||||
|
stats := data.SnapshotRpcRuntimeStats()
|
||||||
|
eth := stats[mdb.NetworkEthereum]
|
||||||
|
if eth.SuccessCount != 0 {
|
||||||
|
t.Fatalf("ethereum success_count = %d, want 0", eth.SuccessCount)
|
||||||
|
}
|
||||||
|
if eth.FailureCount != 1 {
|
||||||
|
t.Fatalf("ethereum failure_count = %d, want 1", eth.FailureCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvmLogBlockHeightRecordsRuntimeHeight(t *testing.T) {
|
||||||
|
data.ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
recordEvmLogBlockHeight(mdb.NetworkEthereum, "[TEST]", 123456)
|
||||||
|
recordEvmLogBlockHeight(mdb.NetworkEthereum, "[TEST]", 123455)
|
||||||
|
|
||||||
|
stats := data.SnapshotRpcRuntimeStats()
|
||||||
|
eth := stats[mdb.NetworkEthereum]
|
||||||
|
if eth.LatestBlockHeight != 123456 {
|
||||||
|
t.Fatalf("ethereum latest_block_height = %d, want 123456", eth.LatestBlockHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvmHeaderBlockHeightRecordsRuntimeHeight(t *testing.T) {
|
||||||
|
data.ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
recordEvmHeaderBlockHeight(mdb.NetworkPolygon, "[TEST]", &types.Header{Number: big.NewInt(7654321)})
|
||||||
|
recordEvmHeaderBlockHeight(mdb.NetworkPolygon, "[TEST]", &types.Header{Number: big.NewInt(7654320)})
|
||||||
|
|
||||||
|
stats := data.SnapshotRpcRuntimeStats()
|
||||||
|
polygon := stats[mdb.NetworkPolygon]
|
||||||
|
if polygon.LatestBlockHeight != 7654321 {
|
||||||
|
t.Fatalf("polygon latest_block_height = %d, want 7654321", polygon.LatestBlockHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldRefreshEvmLatestHeaderAfterIdleInterval(t *testing.T) {
|
||||||
|
lastUpdate := time.Now()
|
||||||
|
|
||||||
|
if shouldRefreshEvmLatestHeader(lastUpdate, false, lastUpdate.Add(evmStatsIdleInterval-time.Second)) {
|
||||||
|
t.Fatal("should not refresh latest header before idle interval")
|
||||||
|
}
|
||||||
|
if !shouldRefreshEvmLatestHeader(lastUpdate, false, lastUpdate.Add(evmStatsIdleInterval)) {
|
||||||
|
t.Fatal("should refresh latest header at idle interval")
|
||||||
|
}
|
||||||
|
if shouldRefreshEvmLatestHeader(lastUpdate, true, lastUpdate.Add(evmStatsIdleInterval+time.Second)) {
|
||||||
|
t.Fatal("should not refresh latest header while request is in flight")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveChainWsURLDisabledRow(t *testing.T) {
|
func TestResolveChainWsURLDisabledRow(t *testing.T) {
|
||||||
cleanup := testutil.SetupTestDatabases(t)
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ func runEthereumListener(contracts []common.Address) {
|
|||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener(ctx, "[ETH-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
runEvmWsLogListener(ctx, mdb.NetworkEthereum, "[ETH-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -100,9 +100,11 @@ func runEthereumListener(contracts []common.Address) {
|
|||||||
var blockTsMs int64
|
var blockTsMs int64
|
||||||
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkEthereum)
|
||||||
log.Sugar.Warnf("[ETH-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
log.Sugar.Warnf("[ETH-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
blockTsMs = time.Now().UnixMilli()
|
blockTsMs = time.Now().UnixMilli()
|
||||||
} else {
|
} else {
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkEthereum)
|
||||||
blockTsMs = int64(header.Time) * 1000
|
blockTsMs = int64(header.Time) * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,12 +14,17 @@ import (
|
|||||||
"github.com/ethereum/go-ethereum/ethclient"
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
evmStatsIdleInterval = 30 * time.Second
|
||||||
|
evmStatsHeaderTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
// runEvmWsLogListener connects to node.Url, subscribes to Transfer logs,
|
// runEvmWsLogListener connects to node.Url, subscribes to Transfer logs,
|
||||||
// and dispatches each log to handleLog. It retries on transient errors
|
// and dispatches each log to handleLog. It retries on transient errors
|
||||||
// with exponential backoff until the node reaches the failure threshold.
|
// with exponential backoff until the node reaches the failure threshold.
|
||||||
// The ctx lets the caller trigger a clean exit — e.g. when admin disables
|
// The ctx lets the caller trigger a clean exit — e.g. when admin disables
|
||||||
// the chain, the caller cancels the context and the function returns.
|
// the chain, the caller cancels the context and the function returns.
|
||||||
func runEvmWsLogListener(ctx context.Context, logPrefix string, node mdb.RpcNode, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
|
func runEvmWsLogListener(ctx context.Context, network string, logPrefix string, node mdb.RpcNode, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
|
||||||
const (
|
const (
|
||||||
minBackoff = 2 * time.Second
|
minBackoff = 2 * time.Second
|
||||||
maxBackoff = 60 * time.Second
|
maxBackoff = 60 * time.Second
|
||||||
@@ -37,8 +42,11 @@ func runEvmWsLogListener(ctx context.Context, logPrefix string, node mdb.RpcNode
|
|||||||
|
|
||||||
client, err := ethclient.Dial(wsURL)
|
client, err := ethclient.Dial(wsURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait)
|
log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait)
|
||||||
if recordEvmWsNodeFailure(logPrefix, node, "dial") {
|
if recordEvmWsNodeFailure(logPrefix, network, node, "dial") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !sleepOrDone(ctx, failWait) {
|
if !sleepOrDone(ctx, failWait) {
|
||||||
@@ -52,8 +60,11 @@ func runEvmWsLogListener(ctx context.Context, logPrefix string, node mdb.RpcNode
|
|||||||
sub, err := client.SubscribeFilterLogs(ctx, query, logsCh)
|
sub, err := client.SubscribeFilterLogs(ctx, query, logsCh)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
client.Close()
|
client.Close()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait)
|
log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait)
|
||||||
if recordEvmWsNodeFailure(logPrefix, node, "subscribe") {
|
if recordEvmWsNodeFailure(logPrefix, network, node, "subscribe") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !sleepOrDone(ctx, failWait) {
|
if !sleepOrDone(ctx, failWait) {
|
||||||
@@ -63,18 +74,22 @@ func runEvmWsLogListener(ctx context.Context, logPrefix string, node mdb.RpcNode
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
failWait = minBackoff
|
failWait = minBackoff
|
||||||
|
data.RecordRpcSuccess(network)
|
||||||
|
|
||||||
log.Sugar.Infof("%s connected, subscribed to Transfer logs using WSS node %s", logPrefix, nodeLabel)
|
log.Sugar.Infof("%s connected, subscribed to Transfer logs using WSS node %s", logPrefix, nodeLabel)
|
||||||
|
|
||||||
connectedAt := time.Now()
|
connectedAt := time.Now()
|
||||||
recvErr := recvLoop(ctx, client, sub, logsCh, logPrefix, handleLog)
|
recvErr := recvLoop(ctx, client, sub, logsCh, network, logPrefix, handleLog)
|
||||||
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if time.Since(connectedAt) >= stableResetAfter {
|
if time.Since(connectedAt) >= stableResetAfter {
|
||||||
|
if recvErr != nil {
|
||||||
|
data.RecordRpcFailure(network)
|
||||||
|
}
|
||||||
data.RecordRpcNodeSuccess(node.ID)
|
data.RecordRpcNodeSuccess(node.ID)
|
||||||
} else if recvErr != nil && recordEvmWsNodeFailure(logPrefix, node, recvErr.Error()) {
|
} else if recvErr != nil && recordEvmWsNodeFailure(logPrefix, network, node, recvErr.Error()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !sleepOrDone(ctx, rejoinWait) {
|
if !sleepOrDone(ctx, rejoinWait) {
|
||||||
@@ -83,8 +98,25 @@ func runEvmWsLogListener(ctx context.Context, logPrefix string, node mdb.RpcNode
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, logPrefix string, handleLog func(*ethclient.Client, types.Log)) error {
|
func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, network string, logPrefix string, handleLog func(*ethclient.Client, types.Log)) error {
|
||||||
|
statsCtx, cancelStats := context.WithCancel(ctx)
|
||||||
|
statsTicker := time.NewTicker(evmStatsIdleInterval)
|
||||||
|
headerStatsDone := make(chan bool, 1)
|
||||||
|
lastStatsUpdateAt := time.Now()
|
||||||
|
headerStatsInFlight := false
|
||||||
|
requestLatestHeader := func() {
|
||||||
|
if headerStatsInFlight {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
headerStatsInFlight = true
|
||||||
|
go func() {
|
||||||
|
headerStatsDone <- recordEvmLatestHeader(statsCtx, network, logPrefix, client)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
|
cancelStats()
|
||||||
|
statsTicker.Stop()
|
||||||
sub.Unsubscribe()
|
sub.Unsubscribe()
|
||||||
client.Close()
|
client.Close()
|
||||||
}()
|
}()
|
||||||
@@ -94,6 +126,15 @@ func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscr
|
|||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
log.Sugar.Infof("%s context cancelled, stopping", logPrefix)
|
log.Sugar.Infof("%s context cancelled, stopping", logPrefix)
|
||||||
return nil
|
return nil
|
||||||
|
case <-statsTicker.C:
|
||||||
|
if shouldRefreshEvmLatestHeader(lastStatsUpdateAt, headerStatsInFlight, time.Now()) {
|
||||||
|
requestLatestHeader()
|
||||||
|
}
|
||||||
|
case updated := <-headerStatsDone:
|
||||||
|
headerStatsInFlight = false
|
||||||
|
if updated {
|
||||||
|
lastStatsUpdateAt = time.Now()
|
||||||
|
}
|
||||||
case err := <-sub.Err():
|
case err := <-sub.Err():
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Warnf("%s subscription error: %v, reconnecting", logPrefix, err)
|
log.Sugar.Warnf("%s subscription error: %v, reconnecting", logPrefix, err)
|
||||||
@@ -107,12 +148,61 @@ func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscr
|
|||||||
log.Sugar.Warnf("%s log channel closed, reconnecting", logPrefix)
|
log.Sugar.Warnf("%s log channel closed, reconnecting", logPrefix)
|
||||||
return fmt.Errorf("log channel closed")
|
return fmt.Errorf("log channel closed")
|
||||||
}
|
}
|
||||||
|
if recordEvmLogBlockHeight(network, logPrefix, vLog.BlockNumber) {
|
||||||
|
lastStatsUpdateAt = time.Now()
|
||||||
|
}
|
||||||
handleLog(client, vLog)
|
handleLog(client, vLog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func recordEvmWsNodeFailure(logPrefix string, node mdb.RpcNode, reason string) bool {
|
func shouldRefreshEvmLatestHeader(lastStatsUpdateAt time.Time, inFlight bool, now time.Time) bool {
|
||||||
|
return !inFlight && now.Sub(lastStatsUpdateAt) >= evmStatsIdleInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordEvmLogBlockHeight(network string, logPrefix string, blockNumber uint64) bool {
|
||||||
|
if blockNumber == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if blockNumber > uint64(1<<63-1) {
|
||||||
|
log.Sugar.Warnf("%s log block number exceeds int64 range: %d", logPrefix, blockNumber)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
data.RecordRpcBlockHeight(network, int64(blockNumber))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordEvmLatestHeader(ctx context.Context, network string, logPrefix string, client *ethclient.Client) bool {
|
||||||
|
headerCtx, cancel := context.WithTimeout(ctx, evmStatsHeaderTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
header, err := client.HeaderByNumber(headerCtx, nil)
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
data.RecordRpcFailure(network)
|
||||||
|
log.Sugar.Warnf("%s latest header stats failed: %v", logPrefix, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
data.RecordRpcSuccess(network)
|
||||||
|
return recordEvmHeaderBlockHeight(network, logPrefix, header)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordEvmHeaderBlockHeight(network string, logPrefix string, header *types.Header) bool {
|
||||||
|
if header == nil || header.Number == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !header.Number.IsInt64() {
|
||||||
|
log.Sugar.Warnf("%s latest header block number exceeds int64 range: %s", logPrefix, header.Number.String())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
data.RecordRpcBlockHeight(network, header.Number.Int64())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordEvmWsNodeFailure(logPrefix string, network string, node mdb.RpcNode, reason string) bool {
|
||||||
|
data.RecordRpcFailure(network)
|
||||||
failures, cooling := data.RecordRpcNodeFailure(node.ID)
|
failures, cooling := data.RecordRpcNodeFailure(node.ID)
|
||||||
nodeLabel := data.RpcNodeLogLabel(node)
|
nodeLabel := data.RpcNodeLogLabel(node)
|
||||||
if !cooling {
|
if !cooling {
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func runPlasmaListener(contracts []common.Address) {
|
|||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener(ctx, "[PLASMA-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
runEvmWsLogListener(ctx, mdb.NetworkPlasma, "[PLASMA-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,9 +97,11 @@ func runPlasmaListener(contracts []common.Address) {
|
|||||||
var blockTsMs int64
|
var blockTsMs int64
|
||||||
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkPlasma)
|
||||||
log.Sugar.Warnf("[PLASMA-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
log.Sugar.Warnf("[PLASMA-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
blockTsMs = time.Now().UnixMilli()
|
blockTsMs = time.Now().UnixMilli()
|
||||||
} else {
|
} else {
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkPlasma)
|
||||||
blockTsMs = int64(header.Time) * 1000
|
blockTsMs = int64(header.Time) * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ func runPolygonListener(contracts []common.Address) {
|
|||||||
Topics: [][]common.Hash{},
|
Topics: [][]common.Hash{},
|
||||||
}
|
}
|
||||||
|
|
||||||
runEvmWsLogListener(ctx, "[POLYGON-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
runEvmWsLogListener(ctx, mdb.NetworkPolygon, "[POLYGON-WS]", wsNode, query, func(client *ethclient.Client, vLog types.Log) {
|
||||||
if len(vLog.Topics) < 3 {
|
if len(vLog.Topics) < 3 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -97,9 +97,11 @@ func runPolygonListener(contracts []common.Address) {
|
|||||||
var blockTsMs int64
|
var blockTsMs int64
|
||||||
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkPolygon)
|
||||||
log.Sugar.Warnf("[POLYGON-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
log.Sugar.Warnf("[POLYGON-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err)
|
||||||
blockTsMs = time.Now().UnixMilli()
|
blockTsMs = time.Now().UnixMilli()
|
||||||
} else {
|
} else {
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkPolygon)
|
||||||
blockTsMs = int64(header.Time) * 1000
|
blockTsMs = int64(header.Time) * 1000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,21 @@ import (
|
|||||||
type ListenSolJob struct{}
|
type ListenSolJob struct{}
|
||||||
|
|
||||||
var gListenSolJobLock sync.Mutex
|
var gListenSolJobLock sync.Mutex
|
||||||
|
var gSolanaBlockHeightLock = make(chan struct{}, 1)
|
||||||
|
|
||||||
func (r ListenSolJob) Run() {
|
func (r ListenSolJob) Run() {
|
||||||
|
shouldRecordHeight := false
|
||||||
|
func() {
|
||||||
gListenSolJobLock.Lock()
|
gListenSolJobLock.Lock()
|
||||||
defer gListenSolJobLock.Unlock()
|
defer gListenSolJobLock.Unlock()
|
||||||
|
|
||||||
log.Sugar.Debug("[ListenSolJob] Job triggered")
|
log.Sugar.Debug("[ListenSolJob] Job triggered")
|
||||||
if !data.IsChainEnabled(mdb.NetworkSolana) {
|
if !data.IsChainEnabled(mdb.NetworkSolana) {
|
||||||
log.Sugar.Debug("[ListenSolJob] chain disabled, skipping")
|
log.Sugar.Debug("[ListenSolJob] chain disabled, skipping")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
shouldRecordHeight = true
|
||||||
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkSolana)
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkSolana)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[ListenSolJob] Failed to get wallet addresses: %v", err)
|
log.Sugar.Errorf("[ListenSolJob] Failed to get wallet addresses: %v", err)
|
||||||
@@ -39,4 +45,26 @@ func (r ListenSolJob) Run() {
|
|||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
log.Sugar.Debug("[ListenSolJob] Job completed")
|
log.Sugar.Debug("[ListenSolJob] Job completed")
|
||||||
|
}()
|
||||||
|
|
||||||
|
if shouldRecordHeight {
|
||||||
|
recordSolanaLatestBlockHeight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordSolanaLatestBlockHeight() {
|
||||||
|
select {
|
||||||
|
case gSolanaBlockHeightLock <- struct{}{}:
|
||||||
|
defer func() { <-gSolanaBlockHeightLock }()
|
||||||
|
default:
|
||||||
|
log.Sugar.Debug("[ListenSolJob] previous Solana block height stats still running, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
height, err := service.SolGetBlockHeight()
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[ListenSolJob] get Solana block height failed: %v", err)
|
||||||
|
} else {
|
||||||
|
data.RecordRpcBlockHeight(mdb.NetworkSolana, height)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package task
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestListenSolJobRecordsRuntimeBlockHeight(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
data.ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
methodCalls := make(map[string]int)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
}
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
methodCalls[req.Method]++
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if req.Method == "getBlockHeight" {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": int64(654321),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": 1,
|
||||||
|
"result": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"signature": "solana-test-signature",
|
||||||
|
"slot": uint64(123456),
|
||||||
|
"err": map[string]interface{}{
|
||||||
|
"InstructionError": []interface{}{0, "Custom"},
|
||||||
|
},
|
||||||
|
"blockTime": nil,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkSolana,
|
||||||
|
Url: server.URL,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Weight: 1,
|
||||||
|
Enabled: true,
|
||||||
|
Purpose: mdb.RpcNodePurposeGeneral,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed solana rpc_node: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&mdb.WalletAddress{
|
||||||
|
Network: mdb.NetworkSolana,
|
||||||
|
Address: "SolTestAddress001",
|
||||||
|
Status: mdb.TokenStatusEnable,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed solana wallet: %v", err)
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(&mdb.ChainToken{
|
||||||
|
Network: mdb.NetworkSolana,
|
||||||
|
Symbol: "SOL",
|
||||||
|
ContractAddress: "",
|
||||||
|
Decimals: 9,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed solana chain_token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ListenSolJob{}.Run()
|
||||||
|
|
||||||
|
if methodCalls["getSignaturesForAddress"] != 1 {
|
||||||
|
t.Fatalf("getSignaturesForAddress calls = %d, want 1", methodCalls["getSignaturesForAddress"])
|
||||||
|
}
|
||||||
|
if methodCalls["getBlockHeight"] != 1 {
|
||||||
|
t.Fatalf("getBlockHeight calls = %d, want 1", methodCalls["getBlockHeight"])
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := data.SnapshotRpcRuntimeStats()
|
||||||
|
solana := stats[mdb.NetworkSolana]
|
||||||
|
if solana.SuccessCount != 2 {
|
||||||
|
t.Fatalf("solana success_count = %d, want 2", solana.SuccessCount)
|
||||||
|
}
|
||||||
|
if solana.FailureCount != 0 {
|
||||||
|
t.Fatalf("solana failure_count = %d, want 0", solana.FailureCount)
|
||||||
|
}
|
||||||
|
if solana.LatestBlockHeight != 654321 {
|
||||||
|
t.Fatalf("solana latest_block_height = %d, want 654321", solana.LatestBlockHeight)
|
||||||
|
}
|
||||||
|
if solana.LastSyncAt.IsZero() {
|
||||||
|
t.Fatal("solana last_sync_at is zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordSolanaLatestBlockHeightSkipsWhenPreviousStatsRunning(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
data.ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
gSolanaBlockHeightLock <- struct{}{}
|
||||||
|
defer func() { <-gSolanaBlockHeightLock }()
|
||||||
|
|
||||||
|
recordSolanaLatestBlockHeight()
|
||||||
|
|
||||||
|
stats := data.SnapshotRpcRuntimeStats()
|
||||||
|
if _, ok := stats[mdb.NetworkSolana]; ok {
|
||||||
|
t.Fatalf("solana stats = %#v, want no stats when height reporter is already running", stats[mdb.NetworkSolana])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,19 +167,32 @@ func doPost(url string, apiKey string, body interface{}) ([]byte, error) {
|
|||||||
func GetNowBlock(baseURL string, apiKey string) (*Block, error) {
|
func GetNowBlock(baseURL string, apiKey string) (*Block, error) {
|
||||||
b, err := doPost(baseURL+"/wallet/getnowblock", apiKey, map[string]interface{}{})
|
b, err := doPost(baseURL+"/wallet/getnowblock", apiKey, map[string]interface{}{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkTron)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var block Block
|
var block Block
|
||||||
return &block, json.Unmarshal(b, &block)
|
if err := json.Unmarshal(b, &block); err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkTron)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkTron)
|
||||||
|
data.RecordRpcBlockHeight(mdb.NetworkTron, block.BlockHeader.RawData.Number)
|
||||||
|
return &block, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetBlockByNum(baseURL string, apiKey string, num int64) (*Block, error) {
|
func GetBlockByNum(baseURL string, apiKey string, num int64) (*Block, error) {
|
||||||
b, err := doPost(baseURL+"/wallet/getblockbynum", apiKey, map[string]interface{}{"num": num})
|
b, err := doPost(baseURL+"/wallet/getblockbynum", apiKey, map[string]interface{}{"num": num})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkTron)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var block Block
|
var block Block
|
||||||
return &block, json.Unmarshal(b, &block)
|
if err := json.Unmarshal(b, &block); err != nil {
|
||||||
|
data.RecordRpcFailure(mdb.NetworkTron)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data.RecordRpcSuccess(mdb.NetworkTron)
|
||||||
|
return &block, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadTronTRC20TokenMap() map[string]mdb.ChainToken {
|
func loadTronTRC20TokenMap() map[string]mdb.ChainToken {
|
||||||
|
|||||||
@@ -110,3 +110,67 @@ func TestTronScannerStopsOnHistoricalBlockFetchError(t *testing.T) {
|
|||||||
t.Fatalf("lastBlock = %d, want 1 so failed block is retried", scanner.lastBlock)
|
t.Fatalf("lastBlock = %d, want 1 so failed block is retried", scanner.lastBlock)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTronRPCRecordsRuntimeStats(t *testing.T) {
|
||||||
|
data.ResetRpcRuntimeStatsForTest()
|
||||||
|
t.Cleanup(data.ResetRpcRuntimeStatsForTest)
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/wallet/getnowblock":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"block_header": map[string]interface{}{
|
||||||
|
"raw_data": map[string]interface{}{
|
||||||
|
"number": 10,
|
||||||
|
"timestamp": int64(1000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"transactions": []interface{}{},
|
||||||
|
})
|
||||||
|
case "/wallet/getblockbynum":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"block_header": map[string]interface{}{
|
||||||
|
"raw_data": map[string]interface{}{
|
||||||
|
"number": 9,
|
||||||
|
"timestamp": int64(1000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"transactions": []interface{}{},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if _, err := GetNowBlock(server.URL, ""); err != nil {
|
||||||
|
t.Fatalf("GetNowBlock(): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := GetBlockByNum(server.URL, "", 9); err != nil {
|
||||||
|
t.Fatalf("GetBlockByNum(): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
failing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "temporary", http.StatusBadGateway)
|
||||||
|
}))
|
||||||
|
defer failing.Close()
|
||||||
|
if _, err := GetBlockByNum(failing.URL, "", 8); err == nil {
|
||||||
|
t.Fatal("GetBlockByNum() error = nil, want failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := data.SnapshotRpcRuntimeStats()
|
||||||
|
tron := stats[mdb.NetworkTron]
|
||||||
|
if tron.SuccessCount != 2 {
|
||||||
|
t.Fatalf("tron success_count = %d, want 2", tron.SuccessCount)
|
||||||
|
}
|
||||||
|
if tron.FailureCount != 1 {
|
||||||
|
t.Fatalf("tron failure_count = %d, want 1", tron.FailureCount)
|
||||||
|
}
|
||||||
|
if tron.LatestBlockHeight != 10 {
|
||||||
|
t.Fatalf("tron latest_block_height = %d, want 10", tron.LatestBlockHeight)
|
||||||
|
}
|
||||||
|
if tron.LastSyncAt.IsZero() {
|
||||||
|
t.Fatal("tron last_sync_at is zero")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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-B2A_wHaW.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-DQbTGPIx.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-D8tn_Xmy.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-DziS5GVn.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-Dq2d4QNz.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-DejrfFke.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-i-R8-Xt2.js";import"./redirect-DMNGvjzO.js";import{a as c,i as l,r as u,s as d,t as f}from"./Match-DdQiF0Vx.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};
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_error-FxJUpx2d.js","assets/search-provider-CqQd6qbw.js","assets/chunk-DECur_0Z.js","assets/dist-CRowTfGU.js","assets/dist-DQohjYYE.js","assets/messages-DejrfFke.js","assets/react-CO2uhaBc.js","assets/confirm-dialog-D5XR6XJY.js","assets/button-Dm9fssyg.js","assets/clsx-D9aGYY3V.js","assets/dist-DdjSJ3_g.js","assets/dist-mGbGD8eM.js","assets/dist-CmaoECTs.js","assets/dist-DTdRHQm1.js","assets/es2015-DEy-OMWh.js","assets/dist-BBD5s6yA.js","assets/dist-DzHXzLCm.js","assets/dist-CxaBirvl.js","assets/dist-BGVVuYLu.js","assets/dist-Bldhsssg.js","assets/dist-BixeH3nX.js","assets/dist-CRSMCRpX.js","assets/separator-Coj2tE7_.js","assets/tooltip-DkC3rDYw.js","assets/dist-xEPJ9mSz.js","assets/dist-CGiJG_yt.js","assets/auth-store-DOyiYUBV.js","assets/dist-CjEMIZpz.js","assets/with-selector-DBfssCrY.js","assets/cookies-BzZOQYPw.js","assets/useRouter-DtTm7XkK.js","assets/useNavigate-7u4DX0Ho.js","assets/useStore-CupyIEEP.js","assets/command-Dt-H1lMw.js","assets/createLucideIcon-Br0Bd5k2.js","assets/dialog-Dzcophi7.js","assets/skeleton-DFUMMbhN.js","assets/wallet-BJXlUb98.js","assets/sun-BwqrnykR.js","assets/shield-check-CiWq1V4z.js","assets/logo-BqAN_RP8.js","assets/input-CMWUoT6V.js","assets/font-provider-CWMuJyEQ.js","assets/theme-provider-BE0vDg1X.js","assets/theme-switch-_lyAuVgs.js","assets/dropdown-menu-DMpwiSwn.js","assets/check-DldThLle.js","assets/header-B6oJkjl-.js","assets/link-Dn0zbpjT.js","assets/ClientOnly-i-R8-Xt2.js","assets/forbidden-DQbTGPIx.js","assets/general-error-DziS5GVn.js","assets/maintenance-error-Dq2d4QNz.js","assets/not-found-error-D8tn_Xmy.js","assets/unauthorized-error-B2A_wHaW.js"])))=>i.map(i=>d[i]);
|
|
||||||
import{n as e,t}from"./lazyRouteComponent-ChzjGjKt.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/_authenticated/errors/$error`)({component:t(()=>n(()=>import(`./_error-FxJUpx2d.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 @@
|
|||||||
|
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-DejrfFke.js";import{m as t}from"./search-provider-CqQd6qbw.js";import{n,t as r}from"./theme-switch-_lyAuVgs.js";import{t as i}from"./general-error-DziS5GVn.js";import{t as a}from"./not-found-error-D8tn_Xmy.js";import{t as o}from"./_error-BAPks1K3.js";import{t as s}from"./unauthorized-error-B2A_wHaW.js";import{t as c}from"./forbidden-DQbTGPIx.js";import{t as l}from"./maintenance-error-Dq2d4QNz.js";import{n as u,r as d,t as f}from"./header-B6oJkjl-.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};
|
|
||||||
@@ -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
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};
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/_trade_id-BLCchCu1.js","assets/chunk-DECur_0Z.js","assets/button-Dm9fssyg.js","assets/clsx-D9aGYY3V.js","assets/messages-DejrfFke.js","assets/react-CO2uhaBc.js","assets/select-CSXV3sWC.js","assets/dist-xEPJ9mSz.js","assets/dist-DQohjYYE.js","assets/dist-CmaoECTs.js","assets/dist-DTdRHQm1.js","assets/dist-CRSMCRpX.js","assets/dist-CRowTfGU.js","assets/dist-Bldhsssg.js","assets/dist-DzHXzLCm.js","assets/dist-mGbGD8eM.js","assets/es2015-DEy-OMWh.js","assets/dist-BixeH3nX.js","assets/dist-CGiJG_yt.js","assets/createLucideIcon-Br0Bd5k2.js","assets/check-DldThLle.js","assets/chevron-down-BPPKEraq.js","assets/auth-store-DOyiYUBV.js","assets/dist-CjEMIZpz.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-CDduNXQq.js","assets/dist-CxaBirvl.js","assets/dist-BBD5s6yA.js","assets/dist-BGVVuYLu.js","assets/loader-circle-DRj7MiMc.js","assets/send-g-CrrC4k.js","assets/triangle-alert-BEkslGAT.js","assets/dialog-Dzcophi7.js","assets/dist-DdjSJ3_g.js","assets/labels-DxFqjJ8j.js","assets/input-CMWUoT6V.js","assets/checkout-model-BfGeSuFW.js"])))=>i.map(i=>d[i]);
|
|
||||||
import{n as e,t}from"./lazyRouteComponent-ChzjGjKt.js";import{t as n}from"./preload-helper-vQKTmgnp.js";var r=e(`/cashier/$trade_id/`)({component:t(()=>n(()=>import(`./_trade_id-BLCchCu1.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])),`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{_n as e,bn as t,gn as n,hn as r,mn as i,np as a,pp as o,tp as s,vn as c,yn as l}from"./messages-DejrfFke.js";import{i as u,n as d,t as f}from"./button-Dm9fssyg.js";import{n as p,r as m}from"./font-provider-CWMuJyEQ.js";import{n as h}from"./theme-provider-BE0vDg1X.js";import{t as g}from"./chevron-down-BPPKEraq.js";import{n as _,t as v}from"./radio-group-CDduNXQq.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-Cl-EVzvm.js";import{t as A}from"./page-header-p3wI3cFk.js";import{t as j}from"./show-submitted-data-DTB4spbA.js";var M=o(),N=b({theme:y([`light`,`dark`]),font:y(m)});function P(){let{font:t,setFont:o}=p(),{theme:l,setTheme:y}=h(),b={theme:l,font:t},A=w({resolver:S(N),defaultValues:b});function P(e){e.font!==t&&o(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:t})=>(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`),...t,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:e()}),(0,M.jsx)(O,{})]})}),(0,M.jsx)(C,{control:A.control,name:`theme`,render:({field:e})=>(0,M.jsxs)(x,{children:[(0,M.jsx)(E,{children:n()}),(0,M.jsx)(D,{children:r()}),(0,M.jsx)(O,{}),(0,M.jsxs)(v,{className:`grid max-w-md grid-cols-2 gap-8 pt-2`,defaultValue:e.value,onValueChange:e.onChange,children:[(0,M.jsx)(x,{children:(0,M.jsxs)(E,{className:`[&:has([data-state=checked])>div]:border-primary`,children:[(0,M.jsx)(T,{children:(0,M.jsx)(_,{className:`sr-only`,value:`light`})}),(0,M.jsx)(`div`,{className:`items-center rounded-md border-2 border-muted p-1 hover:border-accent`,children:(0,M.jsxs)(`div`,{className:`space-y-2 rounded-sm bg-[#ecedef] p-2`,children:[(0,M.jsxs)(`div`,{className:`space-y-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-2 w-20 rounded-lg bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]}),(0,M.jsxs)(`div`,{className:`flex items-center space-x-2 rounded-md bg-white p-2 shadow-xs`,children:[(0,M.jsx)(`div`,{className:`h-4 w-4 rounded-full bg-[#ecedef]`}),(0,M.jsx)(`div`,{className:`h-2 w-25 rounded-lg bg-[#ecedef]`})]})]})}),(0,M.jsx)(`span`,{className:`block w-full p-2 text-center font-normal`,children:a()})]})}),(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:s()})]})})]})]})}),(0,M.jsx)(f,{type:`submit`,children:i()})]})})}function F(){return(0,M.jsx)(A,{description:l(),title:t(),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-DejrfFke.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-DejrfFke.js";import{i as t,o as n,r}from"./button-Dm9fssyg.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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user