mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a36a10e7f |
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
"github.com/GMWalletApp/epusdt/model/request"
|
"github.com/GMWalletApp/epusdt/model/request"
|
||||||
"github.com/GMWalletApp/epusdt/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -147,23 +148,43 @@ func (c *BaseAdminController) CloseOrder(ctx echo.Context) error {
|
|||||||
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
|
// @Router /admin/api/v1/orders/{trade_id}/mark-paid [post]
|
||||||
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
||||||
tradeID := ctx.Param("trade_id")
|
tradeID := ctx.Param("trade_id")
|
||||||
|
adminUserID := currentAdminUserID(ctx)
|
||||||
req := new(MarkPaidRequest)
|
req := new(MarkPaidRequest)
|
||||||
if err := ctx.Bind(req); err != nil {
|
if err := ctx.Bind(req); err != nil {
|
||||||
|
log.Sugar.Warnf("[admin-order] mark-paid bind failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
req.BlockTransactionId = strings.TrimSpace(req.BlockTransactionId)
|
||||||
if err := c.ValidateStruct(ctx, req); err != nil {
|
if err := c.ValidateStruct(ctx, req); err != nil {
|
||||||
|
log.Sugar.Warnf("[admin-order] mark-paid validation failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[admin-order] mark-paid load failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if order.ID == 0 {
|
if order.ID == 0 {
|
||||||
return c.FailJson(ctx, errors.New("order not found"))
|
err = errors.New("order not found")
|
||||||
|
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if order.Status != mdb.StatusWaitPay {
|
if order.Status != mdb.StatusWaitPay {
|
||||||
return c.FailJson(ctx, errors.New("order is not waiting payment"))
|
err = errors.New("order is not waiting payment")
|
||||||
|
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s status=%d block_transaction_id=%s err=%v", adminUserID, tradeID, order.Status, req.BlockTransactionId, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
if !isOnChainOrder(order.PayProvider) {
|
||||||
|
err = errors.New("order is not an on-chain payment order")
|
||||||
|
log.Sugar.Warnf("[admin-order] mark-paid rejected admin_user_id=%d trade_id=%s pay_provider=%s block_transaction_id=%s err=%v", adminUserID, tradeID, order.PayProvider, req.BlockTransactionId, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
verifiedBlockTransactionID, err := service.ValidateManualOrderPayment(order, req.BlockTransactionId)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[admin-order] mark-paid chain verification failed admin_user_id=%d trade_id=%s network=%s token=%s amount=%.8f block_transaction_id=%s err=%v", adminUserID, tradeID, order.Network, order.Token, order.ActualAmount, req.BlockTransactionId, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
req.BlockTransactionId = verifiedBlockTransactionID
|
||||||
err = service.OrderProcessing(&request.OrderProcessingRequest{
|
err = service.OrderProcessing(&request.OrderProcessingRequest{
|
||||||
ReceiveAddress: order.ReceiveAddress,
|
ReceiveAddress: order.ReceiveAddress,
|
||||||
Currency: order.Currency,
|
Currency: order.Currency,
|
||||||
@@ -174,8 +195,10 @@ func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
|||||||
BlockTransactionId: req.BlockTransactionId,
|
BlockTransactionId: req.BlockTransactionId,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[admin-order] mark-paid processing failed admin_user_id=%d trade_id=%s block_transaction_id=%s err=%v", adminUserID, tradeID, req.BlockTransactionId, err)
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
log.Sugar.Infof("[admin-order] mark-paid success admin_user_id=%d trade_id=%s block_transaction_id=%s", adminUserID, tradeID, req.BlockTransactionId)
|
||||||
return c.SucJson(ctx, nil)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,16 +216,46 @@ func (c *BaseAdminController) MarkOrderPaid(ctx echo.Context) error {
|
|||||||
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
|
// @Router /admin/api/v1/orders/{trade_id}/resend-callback [post]
|
||||||
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
|
func (c *BaseAdminController) ResendCallback(ctx echo.Context) error {
|
||||||
tradeID := ctx.Param("trade_id")
|
tradeID := ctx.Param("trade_id")
|
||||||
|
adminUserID := currentAdminUserID(ctx)
|
||||||
|
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||||
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback load failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if order.ID == 0 {
|
||||||
|
err = errors.New("order not found")
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if order.Status != mdb.StatusPaySuccess {
|
||||||
|
err = errors.New("order is not paid (callback not applicable)")
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s status=%d err=%v", adminUserID, tradeID, order.Status, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(order.NotifyUrl) == "" {
|
||||||
|
err = errors.New("order has empty notify_url")
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
|
}
|
||||||
ok, err := data.ReopenOrderCallback(tradeID)
|
ok, err := data.ReopenOrderCallback(tradeID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback reopen failed admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
return c.FailJson(ctx, err)
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return c.FailJson(ctx, errors.New("order is not paid (callback not applicable)"))
|
err = errors.New("resend callback failed (concurrent state change)")
|
||||||
|
log.Sugar.Warnf("[admin-order] resend-callback rejected admin_user_id=%d trade_id=%s err=%v", adminUserID, tradeID, err)
|
||||||
|
return c.FailJson(ctx, err)
|
||||||
}
|
}
|
||||||
|
log.Sugar.Infof("[admin-order] resend-callback queued admin_user_id=%d trade_id=%s", adminUserID, tradeID)
|
||||||
return c.SucJson(ctx, nil)
|
return c.SucJson(ctx, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isOnChainOrder(payProvider string) bool {
|
||||||
|
payProvider = strings.TrimSpace(payProvider)
|
||||||
|
return payProvider == "" || payProvider == mdb.PaymentProviderOnChain
|
||||||
|
}
|
||||||
|
|
||||||
// ExportOrders streams matching orders as CSV. No pagination — the
|
// ExportOrders streams matching orders as CSV. No pagination — the
|
||||||
// caller drives what rows to include via filter params. Capped at
|
// caller drives what rows to include via filter params. Capped at
|
||||||
// 10k rows to keep the response bounded.
|
// 10k rows to keep the response bounded.
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
package comm
|
package comm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
"github.com/GMWalletApp/epusdt/model/service"
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
@@ -10,23 +8,18 @@ import (
|
|||||||
|
|
||||||
// CheckoutCounter 收银台
|
// CheckoutCounter 收银台
|
||||||
// @Summary Checkout counter page
|
// @Summary Checkout counter page
|
||||||
// @Description Render the payment checkout counter JSON response for a given trade
|
// @Description Return checkout initialization data when the order exists. This endpoint only confirms order existence and returns base order data; call /pay/check-status/{trade_id} for the current order status (1=waiting payment, 2=paid, 3=expired).
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param trade_id path string true "Trade ID"
|
// @Param trade_id path string true "Trade ID"
|
||||||
// @Success 200 {object} response.CheckoutCounterResponse
|
// @Success 200 {object} response.ApiResponse{data=response.CheckoutCounterResponse}
|
||||||
|
// @Failure 400 {object} response.ApiResponse "Order not found (status_code=10008) or other request error"
|
||||||
// @Router /pay/checkout-counter-resp/{trade_id} [get]
|
// @Router /pay/checkout-counter-resp/{trade_id} [get]
|
||||||
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
||||||
tradeId := ctx.Param("trade_id")
|
tradeId := ctx.Param("trade_id")
|
||||||
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
|
resp, err := service.GetCheckoutCounterByTradeId(tradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == service.ErrOrder {
|
return c.FailJson(ctx, err)
|
||||||
// Unknown trade id: render the page with empty payload
|
|
||||||
// (client side shows a friendly "order not found" screen).
|
|
||||||
emptyResp := response.CheckoutCounterResponse{}
|
|
||||||
return c.SucJson(ctx, emptyResp)
|
|
||||||
}
|
|
||||||
return ctx.String(http.StatusInternalServerError, err.Error())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.SucJson(ctx, resp)
|
return c.SucJson(ctx, resp)
|
||||||
@@ -34,12 +27,12 @@ func (c *BaseCommController) CheckoutCounter(ctx echo.Context) (err error) {
|
|||||||
|
|
||||||
// CheckStatus 支付状态检测
|
// CheckStatus 支付状态检测
|
||||||
// @Summary Check payment status
|
// @Summary Check payment status
|
||||||
// @Description Check the payment status of an order by trade ID
|
// @Description Return the current order status by trade ID. Status: 1=waiting payment, 2=paid, 3=expired.
|
||||||
// @Tags Payment
|
// @Tags Payment
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param trade_id path string true "Trade ID"
|
// @Param trade_id path string true "Trade ID"
|
||||||
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
|
// @Success 200 {object} response.ApiResponse{data=response.CheckStatusResponse}
|
||||||
// @Failure 400 {object} response.ApiResponse
|
// @Failure 400 {object} response.ApiResponse "Order not found (status_code=10008) or other request error"
|
||||||
// @Router /pay/check-status/{trade_id} [get]
|
// @Router /pay/check-status/{trade_id} [get]
|
||||||
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
func (c *BaseCommController) CheckStatus(ctx echo.Context) (err error) {
|
||||||
tradeId := ctx.Param("trade_id")
|
tradeId := ctx.Param("trade_id")
|
||||||
|
|||||||
@@ -53,23 +53,17 @@ func buildSupportedAssets() ([]response.NetworkTokenSupport, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sort.Strings(symbols)
|
sort.Strings(symbols)
|
||||||
|
displayName := strings.TrimSpace(ch.DisplayName)
|
||||||
|
if displayName == "" {
|
||||||
|
displayName = network
|
||||||
|
}
|
||||||
supports = append(supports, response.NetworkTokenSupport{
|
supports = append(supports, response.NetworkTokenSupport{
|
||||||
Network: network,
|
Network: network,
|
||||||
|
DisplayName: displayName,
|
||||||
Tokens: symbols,
|
Tokens: symbols,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ARC testnet tokens are hardcoded until the chain is formally added to
|
|
||||||
// the database. Show ARC in the list only if there is at least one active
|
|
||||||
// wallet address for the network.
|
|
||||||
arcWallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkArc)
|
|
||||||
if err == nil && len(arcWallets) > 0 {
|
|
||||||
supports = append(supports, response.NetworkTokenSupport{
|
|
||||||
Network: mdb.NetworkArc,
|
|
||||||
Tokens: []string{"EURC", "USDC"},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return supports, nil
|
return supports, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,6 @@ func GetChainByNetwork(network string) (*mdb.Chain, error) {
|
|||||||
// missing row is treated as disabled — scanners must be able to
|
// missing row is treated as disabled — scanners must be able to
|
||||||
// short-circuit even before the admin creates the chain entry.
|
// short-circuit even before the admin creates the chain entry.
|
||||||
func IsChainEnabled(network string) bool {
|
func IsChainEnabled(network string) bool {
|
||||||
if strings.ToLower(strings.TrimSpace(network)) == mdb.NetworkArc {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
row, err := GetChainByNetwork(network)
|
row, err := GetChainByNetwork(network)
|
||||||
if err != nil || row.ID == 0 {
|
if err != nil || row.ID == 0 {
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -108,6 +108,56 @@ func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders,
|
|||||||
return order, err
|
return order, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOrderByBlockTransactionIDs fetches the first order whose stored tx id
|
||||||
|
// matches any equivalent spelling supplied by the caller.
|
||||||
|
func GetOrderByBlockTransactionIDs(blockIDs []string) (*mdb.Orders, error) {
|
||||||
|
order := new(mdb.Orders)
|
||||||
|
seen := make(map[string]struct{}, len(blockIDs))
|
||||||
|
candidates := make([]string, 0, len(blockIDs))
|
||||||
|
for _, blockID := range blockIDs {
|
||||||
|
blockID = strings.TrimSpace(blockID)
|
||||||
|
if blockID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[blockID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[blockID] = struct{}{}
|
||||||
|
candidates = append(candidates, blockID)
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
err := dao.Mdb.Model(order).Where("block_transaction_id IN ?", candidates).Limit(1).Find(order).Error
|
||||||
|
return order, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrderByBlockTransactionIDsCaseInsensitive fetches the first order whose
|
||||||
|
// stored tx id matches any candidate after ASCII case folding. This is used
|
||||||
|
// only for hex-based chain tx ids; case-sensitive signatures must keep using
|
||||||
|
// GetOrderByBlockTransactionIDs.
|
||||||
|
func GetOrderByBlockTransactionIDsCaseInsensitive(blockIDs []string) (*mdb.Orders, error) {
|
||||||
|
order := new(mdb.Orders)
|
||||||
|
seen := make(map[string]struct{}, len(blockIDs))
|
||||||
|
candidates := make([]string, 0, len(blockIDs))
|
||||||
|
for _, blockID := range blockIDs {
|
||||||
|
blockID = strings.ToLower(strings.TrimSpace(blockID))
|
||||||
|
if blockID == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[blockID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[blockID] = struct{}{}
|
||||||
|
candidates = append(candidates, blockID)
|
||||||
|
}
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return order, nil
|
||||||
|
}
|
||||||
|
err := dao.Mdb.Model(order).Where("LOWER(block_transaction_id) IN ?", candidates).Limit(1).Find(order).Error
|
||||||
|
return order, err
|
||||||
|
}
|
||||||
|
|
||||||
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
|
// OrderSuccessWithTransaction marks an order as paid only if it is still waiting for payment.
|
||||||
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
|
func OrderSuccessWithTransaction(tx *gorm.DB, req *request.OrderProcessingRequest) (bool, error) {
|
||||||
result := tx.Model(&mdb.Orders{}).
|
result := tx.Model(&mdb.Orders{}).
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
|
|||||||
// isEVMNetwork 判断是否是 EVM 网络
|
// isEVMNetwork 判断是否是 EVM 网络
|
||||||
func isEVMNetwork(network string) bool {
|
func isEVMNetwork(network string) bool {
|
||||||
switch network {
|
switch network {
|
||||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkArc:
|
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -9,10 +9,9 @@ const (
|
|||||||
NetworkTron = "tron"
|
NetworkTron = "tron"
|
||||||
NetworkSolana = "solana"
|
NetworkSolana = "solana"
|
||||||
NetworkEthereum = "ethereum"
|
NetworkEthereum = "ethereum"
|
||||||
NetworkBsc = "bsc"
|
NetworkBsc = "binance"
|
||||||
NetworkPolygon = "polygon"
|
NetworkPolygon = "polygon"
|
||||||
NetworkPlasma = "plasma"
|
NetworkPlasma = "plasma"
|
||||||
NetworkArc = "arc-testnet"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package response
|
|||||||
|
|
||||||
type NetworkTokenSupport struct {
|
type NetworkTokenSupport struct {
|
||||||
Network string `json:"network" example:"tron"`
|
Network string `json:"network" example:"tron"`
|
||||||
|
DisplayName string `json:"display_name" example:"TRON"`
|
||||||
Tokens []string `json:"tokens" example:"USDT,USDC"`
|
Tokens []string `json:"tokens" example:"USDT,USDC"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,832 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tron "github.com/GMWalletApp/epusdt/crypto"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/math"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
"github.com/ethereum/go-ethereum/ethclient"
|
||||||
|
"github.com/shopspring/decimal"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
const manualVerifyRequestTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
var erc20TransferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
|
||||||
|
|
||||||
|
var (
|
||||||
|
manualOrderPaymentValidatorMu sync.Mutex
|
||||||
|
manualOrderPaymentValidator manualOrderPaymentValidatorFunc = validateManualOrderPaymentDefault
|
||||||
|
)
|
||||||
|
|
||||||
|
type manualOrderPaymentValidatorFunc func(*mdb.Orders, string) (string, error)
|
||||||
|
|
||||||
|
// ValidateManualOrderPayment verifies that the supplied chain transaction
|
||||||
|
// really settles the order before an admin manually marks it paid. It returns
|
||||||
|
// the canonical transaction id that should be persisted for duplicate checks.
|
||||||
|
func ValidateManualOrderPayment(order *mdb.Orders, blockTransactionID string) (string, error) {
|
||||||
|
manualOrderPaymentValidatorMu.Lock()
|
||||||
|
validator := manualOrderPaymentValidator
|
||||||
|
manualOrderPaymentValidatorMu.Unlock()
|
||||||
|
return validator(order, blockTransactionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetManualOrderPaymentValidatorForTest swaps the chain verifier in tests so
|
||||||
|
// route/controller tests don't depend on public RPC availability.
|
||||||
|
func SetManualOrderPaymentValidatorForTest(fn func(*mdb.Orders, string) (string, error)) func() {
|
||||||
|
manualOrderPaymentValidatorMu.Lock()
|
||||||
|
old := manualOrderPaymentValidator
|
||||||
|
if fn == nil {
|
||||||
|
manualOrderPaymentValidator = validateManualOrderPaymentDefault
|
||||||
|
} else {
|
||||||
|
manualOrderPaymentValidator = fn
|
||||||
|
}
|
||||||
|
manualOrderPaymentValidatorMu.Unlock()
|
||||||
|
return func() {
|
||||||
|
manualOrderPaymentValidatorMu.Lock()
|
||||||
|
manualOrderPaymentValidator = old
|
||||||
|
manualOrderPaymentValidatorMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualOrderPaymentDefault(order *mdb.Orders, blockTransactionID string) (string, error) {
|
||||||
|
if order == nil || order.ID == 0 {
|
||||||
|
return "", fmt.Errorf("order not found")
|
||||||
|
}
|
||||||
|
txID := strings.TrimSpace(blockTransactionID)
|
||||||
|
if txID == "" {
|
||||||
|
return "", fmt.Errorf("block_transaction_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
var canonicalTxID string
|
||||||
|
var err error
|
||||||
|
switch strings.ToLower(strings.TrimSpace(order.Network)) {
|
||||||
|
case mdb.NetworkTron:
|
||||||
|
canonicalTxID, err = validateManualTronPayment(order, txID)
|
||||||
|
case mdb.NetworkSolana:
|
||||||
|
canonicalTxID, err = validateManualSolanaPayment(order, txID)
|
||||||
|
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||||
|
canonicalTxID, err = validateManualEvmPayment(order, txID)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("unsupported manual payment verification network: %s", order.Network)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err = ensureManualBlockTransactionUnused(order, canonicalTxID); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return canonicalTxID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureManualBlockTransactionUnused(order *mdb.Orders, canonicalTxID string) error {
|
||||||
|
candidates := equivalentManualBlockTransactionIDs(order.Network, canonicalTxID)
|
||||||
|
var existing *mdb.Orders
|
||||||
|
var err error
|
||||||
|
if manualBlockTransactionIDIsHex(order.Network) {
|
||||||
|
existing, err = data.GetOrderByBlockTransactionIDsCaseInsensitive(candidates)
|
||||||
|
} else {
|
||||||
|
existing, err = data.GetOrderByBlockTransactionIDs(candidates)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if existing.ID > 0 && existing.ID != order.ID {
|
||||||
|
return constant.OrderBlockAlreadyProcess
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func manualBlockTransactionIDIsHex(network string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(network)) {
|
||||||
|
case mdb.NetworkTron, mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equivalentManualBlockTransactionIDs(network, canonicalTxID string) []string {
|
||||||
|
network = strings.ToLower(strings.TrimSpace(network))
|
||||||
|
canonicalTxID = strings.TrimSpace(canonicalTxID)
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
out := make([]string, 0, 6)
|
||||||
|
add := func(value string) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
out = append(out, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
add(canonicalTxID)
|
||||||
|
switch network {
|
||||||
|
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||||
|
body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X")
|
||||||
|
body = strings.ToLower(body)
|
||||||
|
add("0x" + body)
|
||||||
|
add("0x" + strings.ToUpper(body))
|
||||||
|
add("0X" + body)
|
||||||
|
add("0X" + strings.ToUpper(body))
|
||||||
|
add(body)
|
||||||
|
add(strings.ToUpper(body))
|
||||||
|
case mdb.NetworkTron:
|
||||||
|
body := strings.TrimPrefix(strings.TrimPrefix(canonicalTxID, "0x"), "0X")
|
||||||
|
body = strings.ToLower(body)
|
||||||
|
add(body)
|
||||||
|
add(strings.ToUpper(body))
|
||||||
|
add("0x" + body)
|
||||||
|
add("0x" + strings.ToUpper(body))
|
||||||
|
add("0X" + body)
|
||||||
|
add("0X" + strings.ToUpper(body))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualEvmPayment(order *mdb.Orders, txID string) (string, error) {
|
||||||
|
txHash, canonicalTxID, err := canonicalEvmHash(txID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := data.GetEnabledChainTokenBySymbol(order.Network, order.Token)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if token == nil || token.ID == 0 || strings.TrimSpace(token.ContractAddress) == "" {
|
||||||
|
return "", fmt.Errorf("enabled token contract not configured for %s/%s", order.Network, order.Token)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), manualVerifyRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
clients, err := dialManualEvmClients(ctx, order.Network)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer closeManualEvmClients(clients)
|
||||||
|
|
||||||
|
if err = validateManualEvmPaymentAcrossClients(ctx, clients, order, txHash, token); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return canonicalTxID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type evmChainReader interface {
|
||||||
|
TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error)
|
||||||
|
HeaderByNumber(context.Context, *big.Int) (*types.Header, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type manualEvmClient struct {
|
||||||
|
label string
|
||||||
|
reader evmChainReader
|
||||||
|
close func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeManualEvmClients(clients []manualEvmClient) {
|
||||||
|
for _, item := range clients {
|
||||||
|
if item.close != nil {
|
||||||
|
item.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualEvmPaymentAcrossClients(ctx context.Context, clients []manualEvmClient, order *mdb.Orders, txHash common.Hash, token *mdb.ChainToken) error {
|
||||||
|
var verifyErrors []string
|
||||||
|
for _, item := range clients {
|
||||||
|
if err := validateManualEvmPaymentWithClient(ctx, item.reader, order, txHash, token); err != nil {
|
||||||
|
verifyErrors = append(verifyErrors, fmt.Sprintf("%s: %v", item.label, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(verifyErrors) > 0 {
|
||||||
|
return fmt.Errorf("manual EVM verification failed: %s", strings.Join(verifyErrors, "; "))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no enabled %s WS/HTTP RPC node configured", order.Network)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualEvmPaymentWithClient(ctx context.Context, client evmChainReader, order *mdb.Orders, txHash common.Hash, token *mdb.ChainToken) error {
|
||||||
|
receipt, err := client.TransactionReceipt(ctx, txHash)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch transaction receipt: %w", err)
|
||||||
|
}
|
||||||
|
if receipt.Status != types.ReceiptStatusSuccessful {
|
||||||
|
return fmt.Errorf("transaction is not successful")
|
||||||
|
}
|
||||||
|
if receipt.BlockNumber == nil {
|
||||||
|
return fmt.Errorf("transaction receipt missing block number")
|
||||||
|
}
|
||||||
|
txHeader, err := client.HeaderByNumber(ctx, receipt.BlockNumber)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch transaction block header: %w", err)
|
||||||
|
}
|
||||||
|
if err = ensureEvmTransactionNotBeforeOrder(txHeader.Time, order); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = ensureEvmConfirmations(ctx, client, order.Network, receipt.BlockNumber); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
contract, err := normalizeEvmAddress(token.ContractAddress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid token contract address: %w", err)
|
||||||
|
}
|
||||||
|
to, err := normalizeEvmAddress(order.ReceiveAddress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid order receive address: %w", err)
|
||||||
|
}
|
||||||
|
amountMismatch := false
|
||||||
|
for _, item := range receipt.Logs {
|
||||||
|
if item == nil || !strings.EqualFold(item.Address.Hex(), contract.Hex()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(item.Topics) < 3 || item.Topics[0] != erc20TransferEventHash {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(common.BytesToAddress(item.Topics[2].Bytes()).Hex(), to.Hex()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rawAmount := new(big.Int).SetBytes(item.Data)
|
||||||
|
if amountMatchesRaw(order.ActualAmount, rawAmount, token.Decimals) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
amountMismatch = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if amountMismatch {
|
||||||
|
return fmt.Errorf("transaction amount mismatch")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("matching token transfer to order address not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func dialManualEvmClients(ctx context.Context, network string) ([]manualEvmClient, error) {
|
||||||
|
var clients []manualEvmClient
|
||||||
|
var connectErrors []string
|
||||||
|
for _, nodeType := range []string{mdb.RpcNodeTypeWs, mdb.RpcNodeTypeHttp} {
|
||||||
|
node, err := data.SelectRpcNode(network, nodeType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if node == nil || node.ID == 0 || strings.TrimSpace(node.Url) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rpcURL := strings.TrimSpace(node.Url)
|
||||||
|
client, err := ethclient.DialContext(ctx, rpcURL)
|
||||||
|
if err == nil {
|
||||||
|
clients = append(clients, manualEvmClient{
|
||||||
|
label: fmt.Sprintf("%s %s", nodeType, rpcURL),
|
||||||
|
reader: client,
|
||||||
|
close: client.Close,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
connectErrors = append(connectErrors, fmt.Sprintf("%s %s: %v", nodeType, rpcURL, err))
|
||||||
|
}
|
||||||
|
if len(clients) > 0 {
|
||||||
|
return clients, nil
|
||||||
|
}
|
||||||
|
if len(connectErrors) > 0 {
|
||||||
|
return nil, fmt.Errorf("connect %s RPC failed: %s", network, strings.Join(connectErrors, "; "))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no enabled %s WS/HTTP RPC node configured", network)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureEvmTransactionNotBeforeOrder(blockTime uint64, order *mdb.Orders) error {
|
||||||
|
if order == nil {
|
||||||
|
return fmt.Errorf("order not found")
|
||||||
|
}
|
||||||
|
if int64(blockTime)*1000 < order.CreatedAt.TimestampMilli() {
|
||||||
|
return fmt.Errorf("transaction predates the order")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureEvmConfirmations(ctx context.Context, client evmChainReader, network string, txBlock *big.Int) error {
|
||||||
|
chain, err := data.GetChainByNetwork(network)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
minConfirmations := 1
|
||||||
|
if chain != nil && chain.MinConfirmations > 0 {
|
||||||
|
minConfirmations = chain.MinConfirmations
|
||||||
|
}
|
||||||
|
header, err := client.HeaderByNumber(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch latest block: %w", err)
|
||||||
|
}
|
||||||
|
confirmations := new(big.Int).Sub(header.Number, txBlock)
|
||||||
|
confirmations.Add(confirmations, big.NewInt(1))
|
||||||
|
if confirmations.Sign() < 0 || confirmations.Cmp(big.NewInt(int64(minConfirmations))) < 0 {
|
||||||
|
return fmt.Errorf("transaction confirmations %s below required %d", confirmations.String(), minConfirmations)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type tronContractParam struct {
|
||||||
|
TypeURL string `json:"type_url"`
|
||||||
|
Value json.RawMessage `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manualTronTransaction struct {
|
||||||
|
TxID string `json:"txID"`
|
||||||
|
RawData struct {
|
||||||
|
Contract []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Parameter tronContractParam `json:"parameter"`
|
||||||
|
} `json:"contract"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
} `json:"raw_data"`
|
||||||
|
Ret []struct {
|
||||||
|
ContractRet string `json:"contractRet"`
|
||||||
|
} `json:"ret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manualTronTxInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
BlockNumber int64 `json:"blockNumber"`
|
||||||
|
BlockTimeStamp int64 `json:"blockTimeStamp"`
|
||||||
|
ContractResult []string `json:"contractResult"`
|
||||||
|
Log []manualTronEventLog `json:"log"`
|
||||||
|
Receipt struct {
|
||||||
|
Result string `json:"result"`
|
||||||
|
} `json:"receipt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manualTronEventLog struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manualTronBlock struct {
|
||||||
|
BlockHeader struct {
|
||||||
|
RawData struct {
|
||||||
|
Number int64 `json:"number"`
|
||||||
|
} `json:"raw_data"`
|
||||||
|
} `json:"block_header"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type manualTronTransferContractValue struct {
|
||||||
|
OwnerAddress string `json:"owner_address"`
|
||||||
|
ToAddress string `json:"to_address"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualTronPayment(order *mdb.Orders, txID string) (string, error) {
|
||||||
|
normalizedTxID, err := normalizeTronTxID(txID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, apiKey, err := ResolveTronNode()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var tx manualTronTransaction
|
||||||
|
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactionbyid", map[string]interface{}{"value": normalizedTxID}, &tx); err != nil {
|
||||||
|
return "", fmt.Errorf("fetch tron transaction: %w", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(tx.TxID) == "" {
|
||||||
|
return "", fmt.Errorf("transaction not found")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(tx.TxID) != "" && !strings.EqualFold(strings.TrimSpace(tx.TxID), normalizedTxID) {
|
||||||
|
return "", fmt.Errorf("transaction id mismatch")
|
||||||
|
}
|
||||||
|
if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
|
||||||
|
return "", fmt.Errorf("transaction is not successful: %s", tx.Ret[0].ContractRet)
|
||||||
|
}
|
||||||
|
|
||||||
|
var info manualTronTxInfo
|
||||||
|
if err = tronPostJSON(baseURL, apiKey, "/wallet/gettransactioninfobyid", map[string]interface{}{"value": normalizedTxID}, &info); err != nil {
|
||||||
|
return "", fmt.Errorf("fetch tron transaction info: %w", err)
|
||||||
|
}
|
||||||
|
if info.BlockNumber <= 0 {
|
||||||
|
return "", fmt.Errorf("transaction is not confirmed")
|
||||||
|
}
|
||||||
|
if info.Receipt.Result != "" && info.Receipt.Result != "SUCCESS" {
|
||||||
|
return "", fmt.Errorf("transaction is not successful: %s", info.Receipt.Result)
|
||||||
|
}
|
||||||
|
if err = ensureTronConfirmations(baseURL, apiKey, order.Network, info.BlockNumber); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if info.BlockTimeStamp <= 0 {
|
||||||
|
return "", fmt.Errorf("transaction block timestamp missing")
|
||||||
|
}
|
||||||
|
if info.BlockTimeStamp < order.CreatedAt.TimestampMilli() {
|
||||||
|
return "", fmt.Errorf("transaction predates the order")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(strings.TrimSpace(order.Token), "TRX") {
|
||||||
|
if err = validateManualTronNativeTransfer(order, &tx); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return normalizedTxID, nil
|
||||||
|
}
|
||||||
|
if err = validateManualTronTRC20Transfer(order, &tx, &info); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return normalizedTxID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualTronNativeTransfer(order *mdb.Orders, tx *manualTronTransaction) error {
|
||||||
|
if len(tx.RawData.Contract) == 0 || tx.RawData.Contract[0].Type != "TransferContract" {
|
||||||
|
return fmt.Errorf("transaction is not a TRX transfer")
|
||||||
|
}
|
||||||
|
var val manualTronTransferContractValue
|
||||||
|
if err := json.Unmarshal(tx.RawData.Contract[0].Parameter.Value, &val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
toAddr, err := tronHexToAddress(val.ToAddress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid TRX recipient address: %w", err)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(toAddr, order.ReceiveAddress) {
|
||||||
|
return fmt.Errorf("transaction recipient mismatch")
|
||||||
|
}
|
||||||
|
if !amountMatchesRaw(order.ActualAmount, big.NewInt(val.Amount), 6) {
|
||||||
|
return fmt.Errorf("transaction amount mismatch")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualTronTRC20Transfer(order *mdb.Orders, tx *manualTronTransaction, info *manualTronTxInfo) error {
|
||||||
|
token, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, order.Token)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if token == nil || token.ID == 0 || strings.TrimSpace(token.ContractAddress) == "" {
|
||||||
|
return fmt.Errorf("enabled token contract not configured for tron/%s", order.Token)
|
||||||
|
}
|
||||||
|
if len(tx.RawData.Contract) == 0 || tx.RawData.Contract[0].Type != "TriggerSmartContract" {
|
||||||
|
return fmt.Errorf("transaction is not a TRC20 transfer")
|
||||||
|
}
|
||||||
|
contractHex, err := tronAddressToHex(token.ContractAddress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid configured TRC20 contract: %w", err)
|
||||||
|
}
|
||||||
|
return validateManualTronTRC20TransferEvent(order, info, contractHex, token.Decimals)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualTronTRC20TransferEvent(order *mdb.Orders, info *manualTronTxInfo, contractHex string, decimals int) error {
|
||||||
|
if info == nil || len(info.Log) == 0 {
|
||||||
|
return fmt.Errorf("matching TRC20 transfer event to order address not found")
|
||||||
|
}
|
||||||
|
toHex, err := tronAddressToHex(order.ReceiveAddress)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid order receive address: %w", err)
|
||||||
|
}
|
||||||
|
transferTopic := strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x")
|
||||||
|
amountMismatch := false
|
||||||
|
for _, event := range info.Log {
|
||||||
|
eventContractHex, err := normalizeTronAddressHex(event.Address)
|
||||||
|
if err != nil || eventContractHex != contractHex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(event.Topics) < 3 || normalizeEventTopic(event.Topics[0]) != transferTopic {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
eventToHex, err := tronTopicAddressToHex(event.Topics[2])
|
||||||
|
if err != nil || eventToHex != toHex {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rawAmount, err := tronEventDataAmount(event.Data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid TRC20 transfer event amount: %w", err)
|
||||||
|
}
|
||||||
|
if amountMatchesRaw(order.ActualAmount, rawAmount, decimals) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
amountMismatch = true
|
||||||
|
}
|
||||||
|
if amountMismatch {
|
||||||
|
return fmt.Errorf("transaction amount mismatch")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("matching TRC20 transfer event to order address not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureTronConfirmations(baseURL, apiKey, network string, txBlock int64) error {
|
||||||
|
var latest manualTronBlock
|
||||||
|
if err := tronPostJSON(baseURL, apiKey, "/wallet/getnowblock", map[string]interface{}{}, &latest); err != nil {
|
||||||
|
return fmt.Errorf("fetch latest tron block: %w", err)
|
||||||
|
}
|
||||||
|
chain, err := data.GetChainByNetwork(network)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
minConfirmations := 1
|
||||||
|
if chain != nil && chain.MinConfirmations > 0 {
|
||||||
|
minConfirmations = chain.MinConfirmations
|
||||||
|
}
|
||||||
|
confirmations := latest.BlockHeader.RawData.Number - txBlock + 1
|
||||||
|
if confirmations < int64(minConfirmations) {
|
||||||
|
return fmt.Errorf("transaction confirmations %d below required %d", confirmations, minConfirmations)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronPostJSON(baseURL, apiKey, path string, body interface{}, out interface{}) error {
|
||||||
|
payload, _ := json.Marshal(body)
|
||||||
|
req, err := http.NewRequest(http.MethodPost, strings.TrimRight(baseURL, "/")+path, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
|
||||||
|
req.Header.Set("TRON-PRO-API-KEY", apiKey)
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: manualVerifyRequestTimeout}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw))
|
||||||
|
}
|
||||||
|
return json.Unmarshal(raw, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateManualSolanaPayment(order *mdb.Orders, sig string) (string, error) {
|
||||||
|
sig = strings.TrimSpace(sig)
|
||||||
|
txData, err := SolGetTransaction(sig)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fetch solana transaction: %w", err)
|
||||||
|
}
|
||||||
|
if !gjson.GetBytes(txData, "result").Exists() || gjson.GetBytes(txData, "result").Type == gjson.Null {
|
||||||
|
return "", fmt.Errorf("transaction not found")
|
||||||
|
}
|
||||||
|
if err = ensureSolanaConfirmations(order.Network, sig); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
mintTokens := make(map[string]*mdb.ChainToken, len(tokens))
|
||||||
|
var nativeSolToken *mdb.ChainToken
|
||||||
|
for i := range tokens {
|
||||||
|
sym := strings.ToUpper(strings.TrimSpace(tokens[i].Symbol))
|
||||||
|
mint := strings.TrimSpace(tokens[i].ContractAddress)
|
||||||
|
if sym == "SOL" && mint == "" {
|
||||||
|
nativeSolToken = &tokens[i]
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mint != "" {
|
||||||
|
mintTokens[mint] = &tokens[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
|
||||||
|
amountMismatch := false
|
||||||
|
for _, instruction := range instructions {
|
||||||
|
transferInfo, parseErr := ParseTransferInfoFromInstruction(instruction, txData)
|
||||||
|
if parseErr != nil || transferInfo == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !isTransferToAddress(transferInfo, order.ReceiveAddress) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
token, amount := resolveSolTokenAndAmount(transferInfo, mintTokens, nativeSolToken)
|
||||||
|
if !strings.EqualFold(token, order.Token) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err = ensureSolanaTransferNotBeforeOrder(transferInfo.BlockTime, order); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if amountMatchesFloat(order.ActualAmount, amount) {
|
||||||
|
return sig, nil
|
||||||
|
}
|
||||||
|
amountMismatch = true
|
||||||
|
}
|
||||||
|
if amountMismatch {
|
||||||
|
return "", fmt.Errorf("transaction amount mismatch")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("matching solana transfer to order address not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSolanaTransferNotBeforeOrder(blockTime int64, order *mdb.Orders) error {
|
||||||
|
if order == nil {
|
||||||
|
return fmt.Errorf("order not found")
|
||||||
|
}
|
||||||
|
if blockTime <= 0 {
|
||||||
|
return fmt.Errorf("transaction block time missing")
|
||||||
|
}
|
||||||
|
if blockTime*1000 < order.CreatedAt.TimestampMilli() {
|
||||||
|
return fmt.Errorf("transaction predates the order")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureSolanaConfirmations(network, sig string) error {
|
||||||
|
chain, err := data.GetChainByNetwork(network)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
minConfirmations := 1
|
||||||
|
if chain != nil && chain.MinConfirmations > 0 {
|
||||||
|
minConfirmations = chain.MinConfirmations
|
||||||
|
}
|
||||||
|
if minConfirmations <= 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := SolRetryClient("getSignatureStatuses", []interface{}{
|
||||||
|
[]string{sig},
|
||||||
|
map[string]interface{}{"searchTransactionHistory": true},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fetch solana signature status: %w", err)
|
||||||
|
}
|
||||||
|
status := gjson.GetBytes(body, "result.value.0")
|
||||||
|
if !status.Exists() || status.Type == gjson.Null {
|
||||||
|
return fmt.Errorf("transaction status not found")
|
||||||
|
}
|
||||||
|
if errValue := status.Get("err"); errValue.Exists() && errValue.Type != gjson.Null {
|
||||||
|
return fmt.Errorf("transaction is not successful: %s", errValue.Raw)
|
||||||
|
}
|
||||||
|
if status.Get("confirmationStatus").String() == "finalized" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
confirmations := status.Get("confirmations").Int()
|
||||||
|
if confirmations < int64(minConfirmations) {
|
||||||
|
return fmt.Errorf("transaction confirmations %d below required %d", confirmations, minConfirmations)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func amountMatchesRaw(expected float64, rawAmount *big.Int, decimals int) bool {
|
||||||
|
if rawAmount == nil || rawAmount.Sign() <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if decimals < 0 {
|
||||||
|
decimals = 0
|
||||||
|
}
|
||||||
|
actual := decimal.NewFromBigInt(rawAmount, -int32(decimals))
|
||||||
|
return roundedAmount(expected).Equal(actual.Round(int32(data.GetAmountPrecision())))
|
||||||
|
}
|
||||||
|
|
||||||
|
func amountMatchesFloat(expected, actual float64) bool {
|
||||||
|
return roundedAmount(expected).Equal(roundedAmount(actual))
|
||||||
|
}
|
||||||
|
|
||||||
|
func roundedAmount(amount float64) decimal.Decimal {
|
||||||
|
precision := data.GetAmountPrecision()
|
||||||
|
return decimal.NewFromFloat(math.MustParsePrecFloat64(amount, precision)).Round(int32(precision))
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronHexToAddress(hexAddr string) (string, error) {
|
||||||
|
hexAddr, err := normalizeTronAddressHex(hexAddr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
raw, err := hex.DecodeString(hexAddr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return tron.EncodeCheck(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronAddressToHex(addr string) (string, error) {
|
||||||
|
raw, err := tron.DecodeCheck(strings.TrimSpace(addr))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(raw) != 21 || raw[0] != tron.PrefixMainnet {
|
||||||
|
return "", fmt.Errorf("invalid tron address")
|
||||||
|
}
|
||||||
|
return strings.ToLower(hex.EncodeToString(raw)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTronAddressHex(hexAddr string) (string, error) {
|
||||||
|
hexAddr = normalizeTronHexAddress(hexAddr)
|
||||||
|
raw, err := hex.DecodeString(hexAddr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
switch len(raw) {
|
||||||
|
case 20:
|
||||||
|
raw = append([]byte{tron.PrefixMainnet}, raw...)
|
||||||
|
case 21:
|
||||||
|
if raw[0] != tron.PrefixMainnet {
|
||||||
|
return "", fmt.Errorf("invalid tron address prefix")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("invalid address length: %d", len(raw))
|
||||||
|
}
|
||||||
|
return strings.ToLower(hex.EncodeToString(raw)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTronHexAddress(hexAddr string) string {
|
||||||
|
hexAddr = strings.TrimSpace(hexAddr)
|
||||||
|
hexAddr = strings.TrimPrefix(hexAddr, "0x")
|
||||||
|
hexAddr = strings.TrimPrefix(hexAddr, "0X")
|
||||||
|
return strings.ToLower(hexAddr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEventTopic(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, "0x")
|
||||||
|
value = strings.TrimPrefix(value, "0X")
|
||||||
|
return strings.ToLower(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronTopicAddressToHex(topic string) (string, error) {
|
||||||
|
topic = normalizeEventTopic(topic)
|
||||||
|
if len(topic) != 64 {
|
||||||
|
return "", fmt.Errorf("invalid TRON event address topic length")
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(topic); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return normalizeTronAddressHex(topic[24:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func tronEventDataAmount(data string) (*big.Int, error) {
|
||||||
|
data = normalizeEventTopic(data)
|
||||||
|
if data == "" {
|
||||||
|
return nil, fmt.Errorf("empty event data")
|
||||||
|
}
|
||||||
|
if len(data)%2 != 0 {
|
||||||
|
return nil, fmt.Errorf("odd-length event data")
|
||||||
|
}
|
||||||
|
raw, err := hex.DecodeString(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return new(big.Int).SetBytes(raw), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isEvmHash(value string) bool {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, "0x")
|
||||||
|
value = strings.TrimPrefix(value, "0X")
|
||||||
|
if len(value) != 64 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := hex.DecodeString(value)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalEvmHash(value string) (common.Hash, string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, "0x")
|
||||||
|
value = strings.TrimPrefix(value, "0X")
|
||||||
|
if len(value) != 64 {
|
||||||
|
return common.Hash{}, "", fmt.Errorf("invalid EVM transaction hash")
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(value); err != nil {
|
||||||
|
return common.Hash{}, "", err
|
||||||
|
}
|
||||||
|
hash := common.HexToHash("0x" + strings.ToLower(value))
|
||||||
|
return hash, hash.Hex(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTronTxID(value string) (string, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, "0x")
|
||||||
|
value = strings.TrimPrefix(value, "0X")
|
||||||
|
if len(value) != 64 {
|
||||||
|
return "", fmt.Errorf("invalid TRON transaction id length")
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(value); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return strings.ToLower(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeEvmAddress(value string) (common.Address, error) {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
value = strings.TrimPrefix(value, "0x")
|
||||||
|
value = strings.TrimPrefix(value, "0X")
|
||||||
|
if len(value) != 40 {
|
||||||
|
return common.Address{}, fmt.Errorf("invalid address length")
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(value); err != nil {
|
||||||
|
return common.Address{}, err
|
||||||
|
}
|
||||||
|
return common.HexToAddress("0x" + value), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/dromara/carbon/v2"
|
||||||
|
"github.com/ethereum/go-ethereum/common"
|
||||||
|
"github.com/ethereum/go-ethereum/core/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManualVerifyEvmHashAcceptsOptional0x(t *testing.T) {
|
||||||
|
hash := strings.Repeat("a", 64)
|
||||||
|
if !isEvmHash(hash) {
|
||||||
|
t.Fatal("expected bare EVM hash to be valid")
|
||||||
|
}
|
||||||
|
if !isEvmHash("0x" + hash) {
|
||||||
|
t.Fatal("expected 0x-prefixed EVM hash to be valid")
|
||||||
|
}
|
||||||
|
if isEvmHash("0x" + strings.Repeat("a", 63)) {
|
||||||
|
t.Fatal("expected short EVM hash to be invalid")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyNormalizeEvmAddressAcceptsOptional0x(t *testing.T) {
|
||||||
|
addr := "1111111111111111111111111111111111111111"
|
||||||
|
want := common.HexToAddress("0x" + addr)
|
||||||
|
got, err := normalizeEvmAddress(addr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize bare address: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("bare address = %s, want %s", got.Hex(), want.Hex())
|
||||||
|
}
|
||||||
|
got, err = normalizeEvmAddress("0X" + strings.ToUpper(addr))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize prefixed address: %v", err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("prefixed address = %s, want %s", got.Hex(), want.Hex())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyNormalizeTronAddressHexAcceptsOptionalPrefix(t *testing.T) {
|
||||||
|
body := "a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
|
want := "41" + body
|
||||||
|
|
||||||
|
for _, input := range []string{body, "0x" + body, want, "0X" + strings.ToUpper(want)} {
|
||||||
|
got, err := normalizeTronAddressHex(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalizeTronAddressHex(%q): %v", input, err)
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("normalizeTronAddressHex(%q) = %q, want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyNormalizeTronTxIDAcceptsOptional0x(t *testing.T) {
|
||||||
|
txID := strings.Repeat("a", 64)
|
||||||
|
got, err := normalizeTronTxID(txID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize bare txid: %v", err)
|
||||||
|
}
|
||||||
|
if got != txID {
|
||||||
|
t.Fatalf("bare txid = %q, want %q", got, txID)
|
||||||
|
}
|
||||||
|
got, err = normalizeTronTxID("0X" + strings.ToUpper(txID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("normalize prefixed txid: %v", err)
|
||||||
|
}
|
||||||
|
if got != txID {
|
||||||
|
t.Fatalf("prefixed txid = %q, want %q", got, txID)
|
||||||
|
}
|
||||||
|
if _, err = normalizeTronTxID("0x" + strings.Repeat("a", 63)); err == nil {
|
||||||
|
t.Fatal("expected short txid to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyDialEvmClientsIncludesHTTPNode(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Url: "http://127.0.0.1:1",
|
||||||
|
Enabled: true,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create rpc node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
clients, err := dialManualEvmClients(ctx, mdb.NetworkEthereum)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dialManualEvmClients(): %v", err)
|
||||||
|
}
|
||||||
|
defer closeManualEvmClients(clients)
|
||||||
|
if len(clients) != 1 {
|
||||||
|
t.Fatalf("client count = %d, want 1", len(clients))
|
||||||
|
}
|
||||||
|
if !strings.Contains(clients[0].label, mdb.RpcNodeTypeHttp) {
|
||||||
|
t.Fatalf("client label = %q, want HTTP node", clients[0].label)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyEvmRejectsTransactionBeforeOrder(t *testing.T) {
|
||||||
|
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
|
||||||
|
txTime := uint64(time.Now().Add(-time.Hour).Unix())
|
||||||
|
if err := ensureEvmTransactionNotBeforeOrder(txTime, order); err == nil {
|
||||||
|
t.Fatal("expected transaction before order to be rejected")
|
||||||
|
}
|
||||||
|
txTime = uint64(time.Now().Add(time.Minute).Unix())
|
||||||
|
if err := ensureEvmTransactionNotBeforeOrder(txTime, order); err != nil {
|
||||||
|
t.Fatalf("expected transaction after order to pass: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyCanonicalEvmHash(t *testing.T) {
|
||||||
|
hash := strings.Repeat("a", 64)
|
||||||
|
_, canonical, err := canonicalEvmHash("0X" + strings.ToUpper(hash))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("canonicalEvmHash(): %v", err)
|
||||||
|
}
|
||||||
|
if canonical != "0x"+hash {
|
||||||
|
t.Fatalf("canonical hash = %q, want %q", canonical, "0x"+hash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyEquivalentBlockIDsCatchLegacyVariants(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
hash := strings.Repeat("b", 64)
|
||||||
|
mixedHash := "0x" + strings.Repeat("bB", 32)
|
||||||
|
if err := dao.Mdb.Create(&mdb.Orders{
|
||||||
|
TradeId: "paid-legacy-hash",
|
||||||
|
OrderId: "paid-legacy-hash",
|
||||||
|
Status: mdb.StatusPaySuccess,
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
BlockTransactionId: mixedHash,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create existing order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
order := &mdb.Orders{BaseModel: mdb.BaseModel{ID: 999}, Network: mdb.NetworkEthereum}
|
||||||
|
if err := ensureManualBlockTransactionUnused(order, "0x"+hash); err == nil {
|
||||||
|
t.Fatal("expected legacy hash variant to be treated as already processed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyEvmRequestFailureFallsBackToNextClient(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
contract := common.HexToAddress("0x1111111111111111111111111111111111111111")
|
||||||
|
to := common.HexToAddress("0x2222222222222222222222222222222222222222")
|
||||||
|
rawAmount := big.NewInt(1230000)
|
||||||
|
receipt := &types.Receipt{
|
||||||
|
Status: types.ReceiptStatusSuccessful,
|
||||||
|
BlockNumber: big.NewInt(10),
|
||||||
|
Logs: []*types.Log{{
|
||||||
|
Address: contract,
|
||||||
|
Topics: []common.Hash{
|
||||||
|
erc20TransferEventHash,
|
||||||
|
common.Hash{},
|
||||||
|
common.BytesToHash(to.Bytes()),
|
||||||
|
},
|
||||||
|
Data: common.LeftPadBytes(rawAmount.Bytes(), 32),
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
order := &mdb.Orders{
|
||||||
|
Network: mdb.NetworkEthereum,
|
||||||
|
Token: "USDT",
|
||||||
|
ActualAmount: 1.23,
|
||||||
|
ReceiveAddress: to.Hex(),
|
||||||
|
BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().Add(-time.Minute).UnixMilli()))},
|
||||||
|
}
|
||||||
|
token := &mdb.ChainToken{Network: mdb.NetworkEthereum, Symbol: "USDT", ContractAddress: contract.Hex(), Decimals: 6}
|
||||||
|
|
||||||
|
err := validateManualEvmPaymentAcrossClients(context.Background(), []manualEvmClient{
|
||||||
|
{label: "ws bad", reader: &fakeEvmReader{receiptErr: errors.New("ws receipt failed")}},
|
||||||
|
{label: "http ok", reader: &fakeEvmReader{
|
||||||
|
receipt: receipt,
|
||||||
|
headers: map[string]*types.Header{
|
||||||
|
"10": {Number: big.NewInt(10), Time: uint64(time.Now().Unix())},
|
||||||
|
"latest": {Number: big.NewInt(12), Time: uint64(time.Now().Unix())},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}, order, common.HexToHash("0x"+strings.Repeat("c", 64)), token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("validateManualEvmPaymentAcrossClients(): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyTronTRC20UsesTransferEvent(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
contractHex := "411111111111111111111111111111111111111111"
|
||||||
|
recipientHex := "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
|
contractAddress, err := tronHexToAddress(contractHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("contract address: %v", err)
|
||||||
|
}
|
||||||
|
recipientAddress, err := tronHexToAddress(recipientHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recipient address: %v", err)
|
||||||
|
}
|
||||||
|
if err = dao.Mdb.Create(&mdb.ChainToken{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Symbol: "USDT",
|
||||||
|
ContractAddress: contractAddress,
|
||||||
|
Decimals: 6,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawAmount := big.NewInt(1230000)
|
||||||
|
tx := manualTronTransactionFromCallData(t, contractHex, recipientHex, rawAmount)
|
||||||
|
info := &manualTronTxInfo{Log: []manualTronEventLog{{
|
||||||
|
Address: strings.TrimPrefix(contractHex, "41"),
|
||||||
|
Topics: []string{
|
||||||
|
"0x" + strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x"),
|
||||||
|
strings.Repeat("0", 64),
|
||||||
|
"0x" + strings.Repeat("0", 24) + strings.TrimPrefix(recipientHex, "41"),
|
||||||
|
},
|
||||||
|
Data: "0x" + fmt.Sprintf("%064x", rawAmount),
|
||||||
|
}}}
|
||||||
|
order := &mdb.Orders{ReceiveAddress: recipientAddress, Token: "USDT", ActualAmount: 1.23}
|
||||||
|
|
||||||
|
if err = validateManualTronTRC20Transfer(order, &tx, info); err != nil {
|
||||||
|
t.Fatalf("validateManualTronTRC20Transfer(): %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Log[0].Data = "0x" + fmt.Sprintf("%064x", big.NewInt(1240000))
|
||||||
|
if err = validateManualTronTRC20Transfer(order, &tx, info); err == nil {
|
||||||
|
t.Fatal("expected event amount mismatch to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifyTronPaymentHTTPFlow(t *testing.T) {
|
||||||
|
cleanup := testutil.SetupTestDatabases(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
txID := strings.Repeat("a", 64)
|
||||||
|
contractHex := "411111111111111111111111111111111111111111"
|
||||||
|
recipientHex := "41a614f803b6fd780986a42c78ec9c7f77e6ded13c"
|
||||||
|
contractAddress, err := tronHexToAddress(contractHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("contract address: %v", err)
|
||||||
|
}
|
||||||
|
recipientAddress, err := tronHexToAddress(recipientHex)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recipient address: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rawAmount := big.NewInt(1230000)
|
||||||
|
blockTimeMs := time.Now().Add(time.Minute).UnixMilli()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req map[string]string
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
t.Errorf("decode tron request: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path != "/wallet/getnowblock" && req["value"] != txID {
|
||||||
|
t.Errorf("request tx id = %q, want %q", req["value"], txID)
|
||||||
|
http.Error(w, "bad tx id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/wallet/gettransactionbyid":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"txID": txID,
|
||||||
|
"raw_data": map[string]interface{}{
|
||||||
|
"contract": []map[string]interface{}{{
|
||||||
|
"type": "TriggerSmartContract",
|
||||||
|
"parameter": map[string]interface{}{
|
||||||
|
"value": map[string]interface{}{},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
"ret": []map[string]string{{"contractRet": "SUCCESS"}},
|
||||||
|
})
|
||||||
|
case "/wallet/gettransactioninfobyid":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"id": txID,
|
||||||
|
"blockNumber": 100,
|
||||||
|
"blockTimeStamp": blockTimeMs,
|
||||||
|
"receipt": map[string]string{"result": "SUCCESS"},
|
||||||
|
"log": []map[string]interface{}{{
|
||||||
|
"address": strings.TrimPrefix(contractHex, "41"),
|
||||||
|
"topics": []string{
|
||||||
|
strings.TrimPrefix(erc20TransferEventHash.Hex(), "0x"),
|
||||||
|
strings.Repeat("0", 64),
|
||||||
|
"0X" + strings.ToUpper(strings.Repeat("0", 24)+strings.TrimPrefix(recipientHex, "41")),
|
||||||
|
},
|
||||||
|
"data": fmt.Sprintf("%064x", rawAmount),
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
case "/wallet/getnowblock":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"block_header": map[string]interface{}{
|
||||||
|
"raw_data": map[string]interface{}{"number": 110},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
if err = dao.Mdb.Create(&mdb.RpcNode{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Type: mdb.RpcNodeTypeHttp,
|
||||||
|
Url: server.URL,
|
||||||
|
Enabled: true,
|
||||||
|
Status: mdb.RpcNodeStatusOk,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create tron rpc node: %v", err)
|
||||||
|
}
|
||||||
|
if err = dao.Mdb.Create(&mdb.ChainToken{
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Symbol: "USDT",
|
||||||
|
ContractAddress: contractAddress,
|
||||||
|
Decimals: 6,
|
||||||
|
Enabled: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create token: %v", err)
|
||||||
|
}
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: "manual-tron-http-flow",
|
||||||
|
OrderId: "manual-tron-http-flow",
|
||||||
|
ActualAmount: 1.23,
|
||||||
|
ReceiveAddress: recipientAddress,
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
|
}
|
||||||
|
if err = dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("create order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := ValidateManualOrderPayment(order, "0X"+strings.ToUpper(txID))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ValidateManualOrderPayment(): %v", err)
|
||||||
|
}
|
||||||
|
if got != txID {
|
||||||
|
t.Fatalf("canonical tx id = %q, want %q", got, txID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualVerifySolanaRejectsMissingBlockTime(t *testing.T) {
|
||||||
|
order := &mdb.Orders{BaseModel: mdb.BaseModel{CreatedAt: *carbon.NewTime(carbon.CreateFromTimestampMilli(time.Now().UnixMilli()))}}
|
||||||
|
if err := ensureSolanaTransferNotBeforeOrder(0, order); err == nil {
|
||||||
|
t.Fatal("expected missing block time to fail")
|
||||||
|
}
|
||||||
|
if err := ensureSolanaTransferNotBeforeOrder(time.Now().Add(time.Minute).Unix(), order); err != nil {
|
||||||
|
t.Fatalf("expected future block time to pass: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeEvmReader struct {
|
||||||
|
receipt *types.Receipt
|
||||||
|
receiptErr error
|
||||||
|
headers map[string]*types.Header
|
||||||
|
headerErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEvmReader) TransactionReceipt(context.Context, common.Hash) (*types.Receipt, error) {
|
||||||
|
if f.receiptErr != nil {
|
||||||
|
return nil, f.receiptErr
|
||||||
|
}
|
||||||
|
return f.receipt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeEvmReader) HeaderByNumber(_ context.Context, number *big.Int) (*types.Header, error) {
|
||||||
|
if f.headerErr != nil {
|
||||||
|
return nil, f.headerErr
|
||||||
|
}
|
||||||
|
key := "latest"
|
||||||
|
if number != nil {
|
||||||
|
key = number.String()
|
||||||
|
}
|
||||||
|
header := f.headers[key]
|
||||||
|
if header == nil {
|
||||||
|
return nil, fmt.Errorf("missing header %s", key)
|
||||||
|
}
|
||||||
|
return header, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func manualTronTransactionFromCallData(t *testing.T, contractHex, recipientHex string, amount *big.Int) manualTronTransaction {
|
||||||
|
t.Helper()
|
||||||
|
body := strings.TrimPrefix(recipientHex, "41")
|
||||||
|
raw := fmt.Sprintf(`{
|
||||||
|
"raw_data": {
|
||||||
|
"contract": [{
|
||||||
|
"type": "TriggerSmartContract",
|
||||||
|
"parameter": {
|
||||||
|
"value": {
|
||||||
|
"contract_address": %q,
|
||||||
|
"data": %q
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}`, contractHex, "a9059cbb"+strings.Repeat("0", 24)+body+fmt.Sprintf("%064x", amount))
|
||||||
|
var tx manualTronTransaction
|
||||||
|
if err := json.Unmarshal([]byte(raw), &tx); err != nil {
|
||||||
|
t.Fatalf("unmarshal tron tx: %v", err)
|
||||||
|
}
|
||||||
|
return tx
|
||||||
|
}
|
||||||
@@ -45,7 +45,7 @@ func normalizeOrderAddressByNetwork(network, address string) string {
|
|||||||
network = strings.ToLower(strings.TrimSpace(network))
|
network = strings.ToLower(strings.TrimSpace(network))
|
||||||
address = strings.TrimSpace(address)
|
address = strings.TrimSpace(address)
|
||||||
switch network {
|
switch network {
|
||||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkArc:
|
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||||
return strings.ToLower(address)
|
return strings.ToLower(address)
|
||||||
default:
|
default:
|
||||||
return address
|
return address
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/config"
|
"github.com/GMWalletApp/epusdt/config"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/response"
|
"github.com/GMWalletApp/epusdt/model/response"
|
||||||
|
"github.com/GMWalletApp/epusdt/util/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrOrder = errors.New("不存在待支付订单或已过期")
|
// ErrOrder is returned when checkout initialization cannot find the trade id.
|
||||||
|
var ErrOrder = constant.OrderNotExists
|
||||||
|
|
||||||
// GetCheckoutCounterByTradeId returns checkout info for a pending order.
|
// GetCheckoutCounterByTradeId returns checkout initialization data for an existing order.
|
||||||
|
// It does not decide the payment state; callers should use CheckStatus for that.
|
||||||
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
|
func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) {
|
||||||
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
|
orderInfo, err := data.GetOrderInfoByTradeId(tradeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay {
|
if orderInfo.ID <= 0 {
|
||||||
return nil, ErrOrder
|
return nil, ErrOrder
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -183,8 +183,6 @@ func evmChainLogLabel(chainNetwork string) string {
|
|||||||
return "POLYGON"
|
return "POLYGON"
|
||||||
case mdb.NetworkPlasma:
|
case mdb.NetworkPlasma:
|
||||||
return "PLASMA"
|
return "PLASMA"
|
||||||
case mdb.NetworkArc:
|
|
||||||
return "ARC-CHAIN"
|
|
||||||
default:
|
default:
|
||||||
return "EVM"
|
return "EVM"
|
||||||
}
|
}
|
||||||
@@ -207,24 +205,9 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
|
|||||||
log.Sugar.Warnf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
|
log.Sugar.Warnf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tryProcessConfiguredEvmERC20Transfer(chainNetwork, *tokenConfig, toAddr, rawValue, txHash, blockTsMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TryProcessConfiguredEvmERC20Transfer(chainNetwork string, tokenConfig mdb.ChainToken, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
|
|
||||||
defer func() {
|
|
||||||
if err := recover(); err != nil {
|
|
||||||
log.Sugar.Errorf("[%s-WS] TryProcessConfiguredEvmERC20Transfer panic: %v", evmChainLogLabel(chainNetwork), err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
tryProcessConfiguredEvmERC20Transfer(chainNetwork, tokenConfig, toAddr, rawValue, txHash, blockTsMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func tryProcessConfiguredEvmERC20Transfer(chainNetwork string, tokenConfig mdb.ChainToken, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
|
|
||||||
net := evmChainLogLabel(chainNetwork)
|
|
||||||
tokenSym := strings.ToUpper(strings.TrimSpace(tokenConfig.Symbol))
|
tokenSym := strings.ToUpper(strings.TrimSpace(tokenConfig.Symbol))
|
||||||
if tokenSym == "" {
|
if tokenSym == "" {
|
||||||
log.Sugar.Warnf("[%s-WS] skip transfer with empty token symbol")
|
log.Sugar.Warnf("[%s-WS] skip contract %s with empty token symbol", net, contract.Hex())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
walletAddr := strings.ToLower(toAddr.Hex())
|
walletAddr := strings.ToLower(toAddr.Hex())
|
||||||
@@ -274,10 +257,6 @@ func tryProcessConfiguredEvmERC20Transfer(chainNetwork string, tokenConfig mdb.C
|
|||||||
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
|
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
|
|
||||||
log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block time %d is before order create time %d", net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
req := &request.OrderProcessingRequest{
|
req := &request.OrderProcessingRequest{
|
||||||
ReceiveAddress: walletAddr,
|
ReceiveAddress: walletAddr,
|
||||||
@@ -357,8 +336,6 @@ func networkDisplay(n string) string {
|
|||||||
return "Polygon"
|
return "Polygon"
|
||||||
case mdb.NetworkPlasma:
|
case mdb.NetworkPlasma:
|
||||||
return "Plasma"
|
return "Plasma"
|
||||||
case mdb.NetworkArc:
|
|
||||||
return "Arc-Chain"
|
|
||||||
default:
|
default:
|
||||||
if n == "" {
|
if n == "" {
|
||||||
return "Tron"
|
return "Tron"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package route
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/model/dao"
|
"github.com/GMWalletApp/epusdt/model/dao"
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
"github.com/GMWalletApp/epusdt/model/data"
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||||
|
"github.com/GMWalletApp/epusdt/model/service"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -647,6 +649,138 @@ func TestAdminOrders_MarkPaidNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminOrders_MarkPaidSuccessAfterVerification(t *testing.T) {
|
||||||
|
e, token := setupAdminTestEnv(t)
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: "trade-admin-mark-paid-ok",
|
||||||
|
OrderId: "order-admin-mark-paid-ok",
|
||||||
|
Amount: 10,
|
||||||
|
Currency: "CNY",
|
||||||
|
ActualAmount: 1.23,
|
||||||
|
ReceiveAddress: "TTestTronAddress001",
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
NotifyUrl: "https://merchant.example/notify",
|
||||||
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("create order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
verified := false
|
||||||
|
restore := service.SetManualOrderPaymentValidatorForTest(func(got *mdb.Orders, blockID string) (string, error) {
|
||||||
|
verified = true
|
||||||
|
if got.TradeId != order.TradeId {
|
||||||
|
t.Fatalf("validator trade_id = %s, want %s", got.TradeId, order.TradeId)
|
||||||
|
}
|
||||||
|
if blockID != "block-admin-ok" {
|
||||||
|
t.Fatalf("validator block id = %s, want block-admin-ok", blockID)
|
||||||
|
}
|
||||||
|
return "canonical-block-admin-ok", nil
|
||||||
|
})
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
|
||||||
|
"block_transaction_id": "block-admin-ok",
|
||||||
|
}, token)
|
||||||
|
t.Logf("MarkOrderPaid success: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
assertOK(t, rec)
|
||||||
|
if !verified {
|
||||||
|
t.Fatal("expected chain verifier to be called")
|
||||||
|
}
|
||||||
|
|
||||||
|
paid, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload order: %v", err)
|
||||||
|
}
|
||||||
|
if paid.Status != mdb.StatusPaySuccess {
|
||||||
|
t.Fatalf("status = %d, want %d", paid.Status, mdb.StatusPaySuccess)
|
||||||
|
}
|
||||||
|
if paid.BlockTransactionId != "canonical-block-admin-ok" {
|
||||||
|
t.Fatalf("block_transaction_id = %q", paid.BlockTransactionId)
|
||||||
|
}
|
||||||
|
if paid.CallBackConfirm != mdb.CallBackConfirmNo {
|
||||||
|
t.Fatalf("callback_confirm = %d, want %d", paid.CallBackConfirm, mdb.CallBackConfirmNo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminOrders_MarkPaidRejectsVerificationFailure(t *testing.T) {
|
||||||
|
e, token := setupAdminTestEnv(t)
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: "trade-admin-mark-paid-bad-proof",
|
||||||
|
OrderId: "order-admin-mark-paid-bad-proof",
|
||||||
|
Amount: 10,
|
||||||
|
Currency: "CNY",
|
||||||
|
ActualAmount: 1.23,
|
||||||
|
ReceiveAddress: "TTestTronAddress001",
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
NotifyUrl: "https://merchant.example/notify",
|
||||||
|
PayProvider: mdb.PaymentProviderOnChain,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("create order: %v", err)
|
||||||
|
}
|
||||||
|
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
|
||||||
|
return "", errors.New("transaction amount mismatch")
|
||||||
|
})
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
|
||||||
|
"block_transaction_id": "block-admin-bad",
|
||||||
|
}, token)
|
||||||
|
t.Logf("MarkOrderPaid verifier failure: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
if rec.Code == http.StatusOK {
|
||||||
|
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload order: %v", err)
|
||||||
|
}
|
||||||
|
if reloaded.Status != mdb.StatusWaitPay || reloaded.BlockTransactionId != "" {
|
||||||
|
t.Fatalf("order changed after failed verification: status=%d block=%q", reloaded.Status, reloaded.BlockTransactionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminOrders_MarkPaidRejectsNonOnChainOrder(t *testing.T) {
|
||||||
|
e, token := setupAdminTestEnv(t)
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: "trade-admin-mark-paid-provider",
|
||||||
|
OrderId: "order-admin-mark-paid-provider",
|
||||||
|
Amount: 10,
|
||||||
|
Currency: "CNY",
|
||||||
|
ActualAmount: 1.23,
|
||||||
|
ReceiveAddress: "TTestTronAddress001",
|
||||||
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
|
Status: mdb.StatusWaitPay,
|
||||||
|
NotifyUrl: "https://merchant.example/notify",
|
||||||
|
PayProvider: mdb.PaymentProviderOkPay,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("create order: %v", err)
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
restore := service.SetManualOrderPaymentValidatorForTest(func(*mdb.Orders, string) (string, error) {
|
||||||
|
called = true
|
||||||
|
return "block-admin-provider", nil
|
||||||
|
})
|
||||||
|
defer restore()
|
||||||
|
|
||||||
|
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/mark-paid", map[string]interface{}{
|
||||||
|
"block_transaction_id": "block-admin-provider",
|
||||||
|
}, token)
|
||||||
|
t.Logf("MarkOrderPaid provider order: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
if rec.Code == http.StatusOK {
|
||||||
|
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
if called {
|
||||||
|
t.Fatal("verifier should not run for non-on-chain order")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestAdminOrders_ResendCallbackNotFound verifies resend-callback graceful error.
|
// TestAdminOrders_ResendCallbackNotFound verifies resend-callback graceful error.
|
||||||
func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
|
func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
|
||||||
e, token := setupAdminTestEnv(t)
|
e, token := setupAdminTestEnv(t)
|
||||||
@@ -657,6 +791,59 @@ func TestAdminOrders_ResendCallbackNotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAdminOrders_ResendCallbackRejectsEmptyNotifyURL(t *testing.T) {
|
||||||
|
e, token := setupAdminTestEnv(t)
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: "trade-admin-resend-empty-url",
|
||||||
|
OrderId: "order-admin-resend-empty-url",
|
||||||
|
Status: mdb.StatusPaySuccess,
|
||||||
|
CallBackConfirm: mdb.CallBackConfirmOk,
|
||||||
|
CallbackNum: 3,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("create order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
|
||||||
|
t.Logf("ResendCallback empty notify_url: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
if rec.Code == http.StatusOK {
|
||||||
|
t.Fatalf("expected failure, got 200: %s", rec.Body.String())
|
||||||
|
}
|
||||||
|
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload order: %v", err)
|
||||||
|
}
|
||||||
|
if reloaded.CallBackConfirm != mdb.CallBackConfirmOk || reloaded.CallbackNum != 3 {
|
||||||
|
t.Fatalf("callback state changed: confirm=%d num=%d", reloaded.CallBackConfirm, reloaded.CallbackNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAdminOrders_ResendCallbackRequeuesPaidOrder(t *testing.T) {
|
||||||
|
e, token := setupAdminTestEnv(t)
|
||||||
|
order := &mdb.Orders{
|
||||||
|
TradeId: "trade-admin-resend-ok",
|
||||||
|
OrderId: "order-admin-resend-ok",
|
||||||
|
Status: mdb.StatusPaySuccess,
|
||||||
|
NotifyUrl: "https://merchant.example/notify",
|
||||||
|
CallBackConfirm: mdb.CallBackConfirmOk,
|
||||||
|
CallbackNum: 3,
|
||||||
|
}
|
||||||
|
if err := dao.Mdb.Create(order).Error; err != nil {
|
||||||
|
t.Fatalf("create order: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := doPostAdmin(e, "/admin/api/v1/orders/"+order.TradeId+"/resend-callback", nil, token)
|
||||||
|
t.Logf("ResendCallback success: status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
|
assertOK(t, rec)
|
||||||
|
reloaded, err := data.GetOrderInfoByTradeId(order.TradeId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reload order: %v", err)
|
||||||
|
}
|
||||||
|
if reloaded.CallBackConfirm != mdb.CallBackConfirmNo || reloaded.CallbackNum != 0 {
|
||||||
|
t.Fatalf("callback state = confirm %d num %d, want no/0", reloaded.CallBackConfirm, reloaded.CallbackNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
// ─── Dashboard ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200.
|
// TestAdminDashboard_AllRoutes verifies all dashboard endpoints return 200.
|
||||||
|
|||||||
+167
-30
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
"github.com/GMWalletApp/epusdt/util/log"
|
||||||
"github.com/GMWalletApp/epusdt/util/sign"
|
"github.com/GMWalletApp/epusdt/util/sign"
|
||||||
|
"github.com/dromara/carbon/v2"
|
||||||
"github.com/go-resty/resty/v2"
|
"github.com/go-resty/resty/v2"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -578,6 +579,12 @@ func TestGetPublicConfig(t *testing.T) {
|
|||||||
row := item.(map[string]interface{})
|
row := item.(map[string]interface{})
|
||||||
network := row["network"].(string)
|
network := row["network"].(string)
|
||||||
seen[network] = true
|
seen[network] = true
|
||||||
|
if _, ok := row["display_name"].(string); !ok {
|
||||||
|
t.Fatalf("supported_assets[%s].display_name missing or not string: %#v", network, row["display_name"])
|
||||||
|
}
|
||||||
|
if network == "tron" && row["display_name"] != "TRON" {
|
||||||
|
t.Fatalf("supported_assets.tron.display_name = %v, want TRON", row["display_name"])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for _, n := range []string{"tron", "solana"} {
|
for _, n := range []string{"tron", "solana"} {
|
||||||
if !seen[n] {
|
if !seen[n] {
|
||||||
@@ -989,51 +996,64 @@ func TestCheckStatus_NotFound(t *testing.T) {
|
|||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
e.ServeHTTP(rec, req)
|
e.ServeHTTP(rec, req)
|
||||||
t.Logf("CheckStatus(not found): status=%d body=%s", rec.Code, rec.Body.String())
|
t.Logf("CheckStatus(not found): status=%d body=%s", rec.Code, rec.Body.String())
|
||||||
if rec.Code >= 500 {
|
if rec.Code != http.StatusBadRequest {
|
||||||
t.Fatalf("unexpected server error: %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
if rec.Code == http.StatusNotFound && rec.Body.Len() == 0 {
|
var resp map[string]interface{}
|
||||||
t.Fatalf("route returned 404 with empty body — route may not be registered")
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal check-status error response: %v", err)
|
||||||
|
}
|
||||||
|
if got := int(resp["status_code"].(float64)); got != 10008 {
|
||||||
|
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns 200
|
// TestCheckStatus_WithOrder verifies /pay/check-status/:trade_id returns the
|
||||||
// and a status field when the order exists.
|
// real order status for existing orders in all checkout-relevant states.
|
||||||
func TestCheckStatus_WithOrder(t *testing.T) {
|
func TestCheckStatus_WithOrder(t *testing.T) {
|
||||||
e := setupTestEnv(t)
|
e := setupTestEnv(t)
|
||||||
|
|
||||||
// Create an order first via the GMPAY route.
|
cases := []struct {
|
||||||
body := signBody(map[string]interface{}{
|
name string
|
||||||
"order_id": "check-status-001",
|
status int
|
||||||
"amount": 1.00,
|
}{
|
||||||
"token": "usdt",
|
{name: "wait-pay", status: mdb.StatusWaitPay},
|
||||||
"currency": "cny",
|
{name: "paid", status: mdb.StatusPaySuccess},
|
||||||
"network": "tron",
|
{name: "expired", status: mdb.StatusExpired},
|
||||||
"notify_url": "http://localhost/notify",
|
|
||||||
})
|
|
||||||
createRec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
|
||||||
if createRec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("create order failed: %d %s", createRec.Code, createRec.Body.String())
|
|
||||||
}
|
|
||||||
var createResp map[string]interface{}
|
|
||||||
json.Unmarshal(createRec.Body.Bytes(), &createResp)
|
|
||||||
tradeId, _ := createResp["data"].(map[string]interface{})["trade_id"].(string)
|
|
||||||
if tradeId == "" {
|
|
||||||
t.Fatal("no trade_id in create response")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now check status.
|
for _, tc := range cases {
|
||||||
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeId, nil)
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tradeID := createCheckoutCounterRespTestOrder(t, e, "check-status-"+tc.name)
|
||||||
|
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Update("status", tc.status).Error; err != nil {
|
||||||
|
t.Fatalf("update status: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/pay/check-status/"+tradeID, nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
e.ServeHTTP(rec, req)
|
e.ServeHTTP(rec, req)
|
||||||
t.Logf("CheckStatus: status=%d body=%s", rec.Code, rec.Body.String())
|
t.Logf("CheckStatus(%s): status=%d body=%s", tc.name, rec.Code, rec.Body.String())
|
||||||
if rec.Code != http.StatusOK {
|
if rec.Code != http.StatusOK {
|
||||||
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
var resp map[string]interface{}
|
var resp map[string]interface{}
|
||||||
json.Unmarshal(rec.Body.Bytes(), &resp)
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
if resp["data"] == nil {
|
t.Fatalf("unmarshal check-status response: %v", err)
|
||||||
t.Fatal("expected data in check-status response")
|
}
|
||||||
|
data, ok := resp["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected data in check-status response, got: %v", resp)
|
||||||
|
}
|
||||||
|
if got, _ := data["trade_id"].(string); got != tradeID {
|
||||||
|
t.Fatalf("trade_id = %q, want %q", got, tradeID)
|
||||||
|
}
|
||||||
|
if got := int(data["status"].(float64)); got != tc.status {
|
||||||
|
t.Fatalf("status = %d, want %d", got, tc.status)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1061,6 +1081,123 @@ func TestCheckoutCounter_NotFound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckoutCounterResp_ReturnsPaidOrder(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-paid-001")
|
||||||
|
|
||||||
|
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Update("status", mdb.StatusPaySuccess).Error; err != nil {
|
||||||
|
t.Fatalf("mark order paid: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := getCheckoutCounterRespData(t, e, tradeID)
|
||||||
|
if got, _ := data["trade_id"].(string); got != tradeID {
|
||||||
|
t.Fatalf("trade_id = %q, want %q; data=%v", got, tradeID, data)
|
||||||
|
}
|
||||||
|
if got, _ := data["redirect_url"].(string); got != "https://merchant.example/return" {
|
||||||
|
t.Fatalf("redirect_url = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckoutCounterResp_ReturnsExpiredOrder(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
tradeID := createCheckoutCounterRespTestOrder(t, e, "checkout-counter-expired-001")
|
||||||
|
expiredCreatedAt := carbon.Now().SubMinutes(config.GetOrderExpirationTime() + 1)
|
||||||
|
|
||||||
|
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||||
|
Where("trade_id = ?", tradeID).
|
||||||
|
Updates(map[string]interface{}{
|
||||||
|
"status": mdb.StatusExpired,
|
||||||
|
"created_at": expiredCreatedAt,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("mark order expired: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := getCheckoutCounterRespData(t, e, tradeID)
|
||||||
|
if got, _ := data["trade_id"].(string); got != tradeID {
|
||||||
|
t.Fatalf("trade_id = %q, want %q; data=%v", got, tradeID, data)
|
||||||
|
}
|
||||||
|
expirationTime, _ := data["expiration_time"].(float64)
|
||||||
|
if int64(expirationTime) > carbon.Now().TimestampMilli() {
|
||||||
|
t.Fatalf("expiration_time = %.0f, want expired timestamp", expirationTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckoutCounterResp_UnknownOrderReturnsClearError(t *testing.T) {
|
||||||
|
e := setupTestEnv(t)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter-resp/nonexistent-trade-id", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal checkout counter error response: %v", err)
|
||||||
|
}
|
||||||
|
if got := int(resp["status_code"].(float64)); got != 10008 {
|
||||||
|
t.Fatalf("status_code = %d, want 10008; response=%v", got, resp)
|
||||||
|
}
|
||||||
|
if resp["message"] == "" {
|
||||||
|
t.Fatalf("expected error message, got: %v", resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createCheckoutCounterRespTestOrder(t *testing.T, e *echo.Echo, orderID string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
body := signBody(map[string]interface{}{
|
||||||
|
"order_id": orderID,
|
||||||
|
"amount": 1.00,
|
||||||
|
"token": "usdt",
|
||||||
|
"currency": "cny",
|
||||||
|
"network": "tron",
|
||||||
|
"notify_url": "http://localhost/notify",
|
||||||
|
"redirect_url": "https://merchant.example/return",
|
||||||
|
})
|
||||||
|
rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("create order failed: %d %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal create response: %v", err)
|
||||||
|
}
|
||||||
|
data, ok := resp["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected create response data, got: %v", resp)
|
||||||
|
}
|
||||||
|
tradeID, _ := data["trade_id"].(string)
|
||||||
|
if tradeID == "" {
|
||||||
|
t.Fatalf("missing trade_id in create response: %v", resp)
|
||||||
|
}
|
||||||
|
return tradeID
|
||||||
|
}
|
||||||
|
|
||||||
|
func getCheckoutCounterRespData(t *testing.T, e *echo.Echo, tradeID string) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/pay/checkout-counter-resp/"+tradeID, nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
e.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("unmarshal checkout counter response: %v", err)
|
||||||
|
}
|
||||||
|
data, ok := resp["data"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected checkout counter data, got: %v", resp)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
// TestSwitchNetwork_MissingFields verifies that /pay/switch-network validates
|
// TestSwitchNetwork_MissingFields verifies that /pay/switch-network validates
|
||||||
// required fields and returns a graceful error when they are missing.
|
// required fields and returns a graceful error when they are missing.
|
||||||
func TestSwitchNetwork_MissingFields(t *testing.T) {
|
func TestSwitchNetwork_MissingFields(t *testing.T) {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ func Start() {
|
|||||||
go StartBscWebSocketListener()
|
go StartBscWebSocketListener()
|
||||||
go StartPolygonWebSocketListener()
|
go StartPolygonWebSocketListener()
|
||||||
go StartPlasmaWebSocketListener()
|
go StartPlasmaWebSocketListener()
|
||||||
go StartArcWebSocketListener()
|
|
||||||
go StartTronBlockScannerListener()
|
go StartTronBlockScannerListener()
|
||||||
|
|
||||||
c := cron.New()
|
c := cron.New()
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
package task
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"math/big"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/GMWalletApp/epusdt/model/data"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
|
||||||
"github.com/GMWalletApp/epusdt/model/service"
|
|
||||||
"github.com/GMWalletApp/epusdt/util/log"
|
|
||||||
|
|
||||||
"github.com/ethereum/go-ethereum"
|
|
||||||
"github.com/ethereum/go-ethereum/common"
|
|
||||||
"github.com/ethereum/go-ethereum/core/types"
|
|
||||||
"github.com/ethereum/go-ethereum/ethclient"
|
|
||||||
)
|
|
||||||
|
|
||||||
// This is the testnet for Arc USDC/EURC network.
|
|
||||||
const ArcEURCAddress = "0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a"
|
|
||||||
const ArcEURCDecimals = 6
|
|
||||||
const ArcUSDCERC20Address = "0x3600000000000000000000000000000000000000"
|
|
||||||
const ArcUSDCSystemAddress = "0x1800000000000000000000000000000000000000"
|
|
||||||
const ArcNativeUSDCDecimals = 18
|
|
||||||
|
|
||||||
var ArcWs = "wss://rpc.testnet.arc.network"
|
|
||||||
|
|
||||||
const ArcChainID = "5042002"
|
|
||||||
|
|
||||||
type arcRecipientSnapshot struct {
|
|
||||||
addrs map[string]struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
var arcWatchedRecipients atomic.Pointer[arcRecipientSnapshot]
|
|
||||||
|
|
||||||
var arcERC20Tokens = []mdb.ChainToken{
|
|
||||||
{Network: mdb.NetworkArc, Symbol: "EURC", ContractAddress: ArcEURCAddress, Decimals: ArcEURCDecimals, Enabled: true},
|
|
||||||
}
|
|
||||||
|
|
||||||
var arcNativeUSDCTransferEventHash = common.HexToHash("0x62f084c00a442dcf51cdbb51beed2839bf42a268da8474b0e98f38edb7db5a22")
|
|
||||||
|
|
||||||
func StartArcWebSocketListener() {
|
|
||||||
for {
|
|
||||||
runArcListener()
|
|
||||||
time.Sleep(10 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func runArcListener() {
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkArc)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Errorf("[ARC-CHAIN-WS] Failed to get wallet addresses: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
storeArcRecipientsFromWallets(wallets)
|
|
||||||
go func() {
|
|
||||||
ticker := time.NewTicker(5 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case <-ticker.C:
|
|
||||||
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkArc)
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Warnf("[ARC-CHAIN-WS] refresh wallet addresses: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
storeArcRecipientsFromWallets(w)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
contracts := make([]common.Address, 0, len(arcERC20Tokens)+1)
|
|
||||||
tokenByContract := make(map[string]mdb.ChainToken, len(arcERC20Tokens)+1)
|
|
||||||
for _, token := range arcERC20Tokens {
|
|
||||||
contract := common.HexToAddress(token.ContractAddress)
|
|
||||||
contracts = append(contracts, contract)
|
|
||||||
tokenByContract[strings.ToLower(contract.Hex())] = token
|
|
||||||
}
|
|
||||||
|
|
||||||
// Arc USDC is a native gas token. Its single source of truth for balance
|
|
||||||
// movement logs is the native-token system contract, not the ERC-20 facade.
|
|
||||||
usdcSystemContract := common.HexToAddress(ArcUSDCSystemAddress)
|
|
||||||
contracts = append(contracts, usdcSystemContract)
|
|
||||||
tokenByContract[strings.ToLower(usdcSystemContract.Hex())] = mdb.ChainToken{
|
|
||||||
Network: mdb.NetworkArc,
|
|
||||||
Symbol: "USDC",
|
|
||||||
ContractAddress: ArcUSDCERC20Address,
|
|
||||||
Decimals: ArcNativeUSDCDecimals,
|
|
||||||
Enabled: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ARC_WSS env var overrides the default testnet WebSocket URL.
|
|
||||||
if arcws := os.Getenv("ARC_WSS"); arcws != "" {
|
|
||||||
ArcWs = arcws
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Sugar.Infof("[ARC-CHAIN-WS] connecting to %s chain_id=%s watching %d contract(s)", ArcWs, ArcChainID, len(contracts))
|
|
||||||
|
|
||||||
query := ethereum.FilterQuery{
|
|
||||||
Addresses: contracts,
|
|
||||||
Topics: [][]common.Hash{{transferEventHash, arcNativeUSDCTransferEventHash}},
|
|
||||||
}
|
|
||||||
|
|
||||||
runEvmWsLogListener(ctx, "[ARC-CHAIN-WS]", ArcWs, query, func(client *ethclient.Client, vLog types.Log) {
|
|
||||||
handleArcTransferLog(client, tokenByContract, "[ARC-CHAIN-WS]", vLog)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func handleArcTransferLog(client *ethclient.Client, tokenByContract map[string]mdb.ChainToken, logPrefix string, vLog types.Log) {
|
|
||||||
if vLog.Removed {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if len(vLog.Topics) < 3 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !isArcTransferEvent(vLog) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toAddr := common.HexToAddress(vLog.Topics[2].Hex())
|
|
||||||
if !isWatchedArcRecipient(toAddr) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
token, ok := tokenByContract[strings.ToLower(vLog.Address.Hex())]
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
amount := new(big.Int).SetBytes(vLog.Data)
|
|
||||||
log.Sugar.Infof("%s matched %s transfer contract=%s to=%s raw_amount=%s tx_hash=%s block=%d", logPrefix, token.Symbol, vLog.Address.Hex(), toAddr.Hex(), amount.String(), vLog.TxHash.Hex(), vLog.BlockNumber)
|
|
||||||
|
|
||||||
var blockTsMs int64
|
|
||||||
header, err := client.HeaderByNumber(context.Background(), new(big.Int).SetUint64(vLog.BlockNumber))
|
|
||||||
if err != nil {
|
|
||||||
log.Sugar.Warnf("%s HeaderByNumber block=%d: %v, using local time", logPrefix, vLog.BlockNumber, err)
|
|
||||||
blockTsMs = time.Now().UnixMilli()
|
|
||||||
} else {
|
|
||||||
blockTsMs = int64(header.Time) * 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
service.TryProcessConfiguredEvmERC20Transfer(mdb.NetworkArc, token, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isArcTransferEvent(vLog types.Log) bool {
|
|
||||||
if len(vLog.Topics) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if strings.EqualFold(vLog.Address.Hex(), ArcUSDCSystemAddress) {
|
|
||||||
return vLog.Topics[0] == arcNativeUSDCTransferEventHash
|
|
||||||
}
|
|
||||||
if vLog.Topics[0] == transferEventHash {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func storeArcRecipientsFromWallets(wallets []mdb.WalletAddress) int {
|
|
||||||
m := make(map[string]struct{})
|
|
||||||
for _, w := range wallets {
|
|
||||||
a := strings.TrimSpace(w.Address)
|
|
||||||
if !common.IsHexAddress(a) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{}
|
|
||||||
}
|
|
||||||
arcWatchedRecipients.Store(&arcRecipientSnapshot{addrs: m})
|
|
||||||
return len(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
func isWatchedArcRecipient(to common.Address) bool {
|
|
||||||
snap := arcWatchedRecipients.Load()
|
|
||||||
if snap == nil || len(snap.addrs) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, ok := snap.addrs[strings.ToLower(to.Hex())]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user