mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 10:16:15 +00:00
adjust
This commit is contained in:
@@ -8,15 +8,15 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/assimon/luuu/util/math"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/math"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
@@ -33,8 +33,28 @@ var (
|
||||
gOrderProcessingLock sync.Mutex
|
||||
)
|
||||
|
||||
// apiKeyID safely extracts the primary key from an ApiKey row.
|
||||
// Returns 0 when apiKey is nil (middleware didn't run — shouldn't happen on authed routes).
|
||||
func apiKeyID(apiKey *mdb.ApiKey) uint64 {
|
||||
if apiKey == nil {
|
||||
return 0
|
||||
}
|
||||
return apiKey.ID
|
||||
}
|
||||
|
||||
func normalizeOrderAddressByNetwork(network, address string) string {
|
||||
network = strings.ToLower(strings.TrimSpace(network))
|
||||
address = strings.TrimSpace(address)
|
||||
switch network {
|
||||
case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma:
|
||||
return strings.ToLower(address)
|
||||
default:
|
||||
return address
|
||||
}
|
||||
}
|
||||
|
||||
// CreateTransaction creates a new payment order.
|
||||
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
|
||||
func CreateTransaction(req *request.CreateTransactionRequest, apiKey *mdb.ApiKey) (*response.CreateTransactionResponse, error) {
|
||||
gCreateTransactionLock.Lock()
|
||||
defer gCreateTransactionLock.Unlock()
|
||||
|
||||
@@ -64,6 +84,9 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
||||
return nil, constant.OrderAlreadyExists
|
||||
}
|
||||
|
||||
if !data.IsChainEnabled(network) {
|
||||
return nil, constant.ChainNotEnabled
|
||||
}
|
||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -97,6 +120,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
||||
RedirectUrl: req.RedirectUrl,
|
||||
Name: req.Name,
|
||||
PaymentType: req.PaymentType,
|
||||
ApiKeyID: apiKeyID(apiKey),
|
||||
}
|
||||
if err = data.CreateOrderWithTransaction(tx, order); err != nil {
|
||||
tx.Rollback()
|
||||
@@ -161,7 +185,7 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
||||
// Load order to check parent-child relationship
|
||||
order, err := data.GetOrderInfoByTradeId(req.TradeId)
|
||||
if err != nil {
|
||||
return nil
|
||||
return fmt.Errorf("load paid order failed, trade_id=%s: %w", req.TradeId, err)
|
||||
}
|
||||
|
||||
// Parent order paid directly: expire all sub-orders and release their locks
|
||||
@@ -169,7 +193,7 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
||||
subs, subErr := data.GetActiveSubOrders(order.TradeId)
|
||||
if subErr != nil {
|
||||
log.Sugar.Errorf("[order] get sub-orders for parent failed, trade_id=%s, err=%v", order.TradeId, subErr)
|
||||
return nil
|
||||
return fmt.Errorf("load sub-orders failed, parent_trade_id=%s: %w", order.TradeId, subErr)
|
||||
}
|
||||
for _, sub := range subs {
|
||||
if err = data.ExpireOrderByTradeId(sub.TradeId); err != nil {
|
||||
@@ -182,22 +206,48 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sub-order should not trigger its own callback (notify_url is empty).
|
||||
// OrderSuccessWithTransaction unconditionally sets callback_confirm=No,
|
||||
// reset it here to prevent the callback worker from retrying an empty URL.
|
||||
if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil {
|
||||
log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err)
|
||||
}
|
||||
|
||||
parent, err := data.GetOrderInfoByTradeId(order.ParentTradeId)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[order] load parent order failed, parent_trade_id=%s, err=%v", order.ParentTradeId, err)
|
||||
return nil
|
||||
return fmt.Errorf("load parent order failed, parent_trade_id=%s: %w", order.ParentTradeId, err)
|
||||
}
|
||||
|
||||
// Snapshot siblings for lock release after DB state transition commits.
|
||||
siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||
return fmt.Errorf("load sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
||||
}
|
||||
|
||||
finalizeTx := dao.Mdb.Begin()
|
||||
|
||||
// Mark parent as paid with sub-order's payment details
|
||||
if _, err = data.MarkParentOrderSuccess(parent.TradeId, order); err != nil {
|
||||
log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||
updatedParent, markErr := data.MarkParentOrderSuccessWithTransaction(finalizeTx, parent.TradeId, order)
|
||||
if markErr != nil {
|
||||
finalizeTx.Rollback()
|
||||
log.Sugar.Errorf("[order] mark parent success failed, parent_trade_id=%s, err=%v", parent.TradeId, markErr)
|
||||
return fmt.Errorf("mark parent success failed, parent_trade_id=%s: %w", parent.TradeId, markErr)
|
||||
}
|
||||
if !updatedParent {
|
||||
finalizeTx.Rollback()
|
||||
return fmt.Errorf("parent order not updated, trade_id=%s is not in wait-pay status", parent.TradeId)
|
||||
}
|
||||
|
||||
if err = data.ExpireSiblingSubOrdersWithTransaction(finalizeTx, parent.TradeId, order.TradeId); err != nil {
|
||||
finalizeTx.Rollback()
|
||||
return fmt.Errorf("expire sibling sub-orders failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
||||
}
|
||||
|
||||
if err = finalizeTx.Commit().Error; err != nil {
|
||||
finalizeTx.Rollback()
|
||||
return fmt.Errorf("commit parent finalize tx failed, parent_trade_id=%s: %w", parent.TradeId, err)
|
||||
}
|
||||
|
||||
// Sub-order should not trigger its own callback (notify_url is empty).
|
||||
// OrderSuccessWithTransaction unconditionally sets callback_confirm=No,
|
||||
// reset it only after the parent order is successfully finalized.
|
||||
if err = data.ResetCallbackConfirmOk(order.TradeId); err != nil {
|
||||
log.Sugar.Warnf("[order] reset sub-order callback_confirm failed, trade_id=%s, err=%v", order.TradeId, err)
|
||||
}
|
||||
|
||||
// Release parent's own wallet lock
|
||||
@@ -205,16 +255,8 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
||||
log.Sugar.Warnf("[order] unlock parent transaction failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||
}
|
||||
|
||||
// Expire sibling sub-orders and release their locks
|
||||
siblings, err := data.GetSiblingSubOrders(parent.TradeId, order.TradeId)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[order] get sibling sub-orders failed, parent_trade_id=%s, err=%v", parent.TradeId, err)
|
||||
return nil
|
||||
}
|
||||
// Release sibling locks after their status transitions commit.
|
||||
for _, sib := range siblings {
|
||||
if err = data.ExpireOrderByTradeId(sib.TradeId); err != nil {
|
||||
log.Sugar.Warnf("[order] expire sibling failed, trade_id=%s, err=%v", sib.TradeId, err)
|
||||
}
|
||||
if err = data.UnLockTransaction(sib.Network, sib.ReceiveAddress, sib.Token, sib.ActualAmount); err != nil {
|
||||
log.Sugar.Warnf("[order] unlock sibling transaction failed, trade_id=%s, err=%v", sib.TradeId, err)
|
||||
}
|
||||
@@ -230,9 +272,10 @@ func ReserveAvailableWalletAndAmount(tradeID string, network string, token strin
|
||||
|
||||
tryLockWalletFunc := func(targetAmount float64) (string, error) {
|
||||
for _, address := range walletAddress {
|
||||
err := data.LockTransaction(network, address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
|
||||
normalizedAddress := normalizeOrderAddressByNetwork(network, address.Address)
|
||||
err := data.LockTransaction(network, normalizedAddress, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
|
||||
if err == nil {
|
||||
return address.Address, nil
|
||||
return normalizedAddress, nil
|
||||
}
|
||||
if errors.Is(err, data.ErrTransactionLocked) {
|
||||
continue
|
||||
@@ -344,6 +387,9 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
||||
}
|
||||
|
||||
// 6. Find and lock wallet
|
||||
if !data.IsChainEnabled(network) {
|
||||
return nil, constant.ChainNotEnabled
|
||||
}
|
||||
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -381,6 +427,7 @@ func SwitchNetwork(req *request.SwitchNetworkRequest) (*response.CheckoutCounter
|
||||
Name: parent.Name,
|
||||
CallBackConfirm: mdb.CallBackConfirmOk, // don't trigger callback on sub-order
|
||||
PaymentType: parent.PaymentType,
|
||||
ApiKeyID: parent.ApiKeyID, // inherit from parent so resolveOrderApiKey never fails
|
||||
}
|
||||
if err = data.CreateOrderWithTransaction(tx, subOrder); err != nil {
|
||||
tx.Rollback()
|
||||
|
||||
@@ -2,15 +2,21 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/internal/testutil"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/http_client"
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest {
|
||||
@@ -24,6 +30,26 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func installMockHTTPClient(t *testing.T, handler roundTripFunc) {
|
||||
t.Helper()
|
||||
|
||||
oldFactory := http_client.ClientFactory
|
||||
http_client.ClientFactory = func() *resty.Client {
|
||||
client := resty.NewWithClient(&http.Client{Transport: handler})
|
||||
client.SetTimeout(10 * time.Second)
|
||||
return client
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
http_client.ClientFactory = oldFactory
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
@@ -32,11 +58,11 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create first transaction: %v", err)
|
||||
}
|
||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1))
|
||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create second transaction: %v", err)
|
||||
}
|
||||
@@ -71,6 +97,102 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionUsesRateAPIWhenForcedSettingIsNotPositive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
installMockHTTPClient(t, func(r *http.Request) (*http.Response, error) {
|
||||
if r.URL.Path != "/cny.json" {
|
||||
t.Fatalf("rate api path = %s, want /cny.json", r.URL.Path)
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"cny":{"usdt":0.14635}}`)),
|
||||
Request: r,
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
|
||||
t.Fatalf("set rate.forced_usdt_rate: %v", err)
|
||||
}
|
||||
if err := data.SetSetting("rate", "rate.api_url", "https://rate.example.test", "string"); err != nil {
|
||||
t.Fatalf("set rate.api_url: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddress("wallet_1"); err != nil {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_api_rate_1", 10), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
if got := fmt.Sprintf("%.2f", resp.ActualAmount); got != "1.46" {
|
||||
t.Fatalf("actual amount = %s, want 1.46", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionFailsWhenRateAPIUnavailableAndForcedSettingIsNotPositive(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if err := data.SetSetting("rate", "rate.forced_usdt_rate", "0", "string"); err != nil {
|
||||
t.Fatalf("set rate.forced_usdt_rate: %v", err)
|
||||
}
|
||||
if err := data.SetSetting("rate", "rate.api_url", "", "string"); err != nil {
|
||||
t.Fatalf("clear rate.api_url: %v", err)
|
||||
}
|
||||
|
||||
_, err := CreateTransaction(newCreateTransactionRequest("order_missing_rate_1", 10), nil)
|
||||
if err != constant.RateAmountErr {
|
||||
t.Fatalf("create transaction error = %v, want %v", err, constant.RateAmountErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTransactionNormalizesEvmReceiveAddressToLowercase(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
mixedAddress := "0xA1B2c3D4e5F60718293aBcDeF001122334455667"
|
||||
if err := dao.Mdb.Create(&mdb.WalletAddress{
|
||||
Network: mdb.NetworkEthereum,
|
||||
Address: mixedAddress,
|
||||
Status: mdb.TokenStatusEnable,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("seed mixed-case wallet: %v", err)
|
||||
}
|
||||
|
||||
req := newCreateTransactionRequest("order_evm_1", 1)
|
||||
req.Network = mdb.NetworkEthereum
|
||||
|
||||
resp, err := CreateTransaction(req, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
|
||||
expectedAddress := strings.ToLower(mixedAddress)
|
||||
if resp.ReceiveAddress != expectedAddress {
|
||||
t.Fatalf("receive address = %q, want %q", resp.ReceiveAddress, expectedAddress)
|
||||
}
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(resp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("load order: %v", err)
|
||||
}
|
||||
if order.ReceiveAddress != expectedAddress {
|
||||
t.Fatalf("stored order address = %q, want %q", order.ReceiveAddress, expectedAddress)
|
||||
}
|
||||
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, strings.ToUpper(mixedAddress), resp.Token, resp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("lookup runtime lock: %v", err)
|
||||
}
|
||||
if tradeID != resp.TradeId {
|
||||
t.Fatalf("runtime lock trade_id = %q, want %q", tradeID, resp.TradeId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
@@ -79,7 +201,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
@@ -127,7 +249,7 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
@@ -169,7 +291,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
||||
resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create transaction: %v", err)
|
||||
}
|
||||
@@ -215,11 +337,11 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
|
||||
t.Fatalf("add wallet: %v", err)
|
||||
}
|
||||
|
||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1))
|
||||
resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create first transaction: %v", err)
|
||||
}
|
||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2))
|
||||
resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create second transaction: %v", err)
|
||||
}
|
||||
@@ -303,3 +425,285 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
|
||||
t.Fatalf("paid=%d pending=%d, want 1 and 1", paidCount, pendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingSubOrderReturnsErrorWhenParentNotWaitPay(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_parent_for_sub", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch network create sub-order: %v", err)
|
||||
}
|
||||
|
||||
if err := dao.Mdb.Model(&mdb.Orders{}).
|
||||
Where("trade_id = ?", parentResp.TradeId).
|
||||
Update("status", mdb.StatusExpired).Error; err != nil {
|
||||
t.Fatalf("force parent expired: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subResp.Token),
|
||||
Network: strings.ToLower(subResp.Network),
|
||||
TradeId: subResp.TradeId,
|
||||
Amount: subResp.ActualAmount,
|
||||
BlockTransactionId: "block_sub_parent_not_wait",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent order is not wait-pay")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrderProcessingSubOrderReturnsErrorWhenParentMissing(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_parent_missing", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch network create sub-order: %v", err)
|
||||
}
|
||||
|
||||
if err := dao.Mdb.Where("trade_id = ?", parentResp.TradeId).Delete(&mdb.Orders{}).Error; err != nil {
|
||||
t.Fatalf("delete parent order: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subResp.Token),
|
||||
Network: strings.ToLower(subResp.Network),
|
||||
TradeId: subResp.TradeId,
|
||||
Amount: subResp.ActualAmount,
|
||||
BlockTransactionId: "block_sub_parent_missing",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parent order is missing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderProcessingSubOrderPaidParentKeepsOwnFields verifies the new behavior:
|
||||
// when a sub-order is paid, the parent order is marked as paid but its own
|
||||
// block_transaction_id, actual_amount, and receive_address are NOT overwritten.
|
||||
// The sub-order's primary-key ID is recorded in the parent's pay_by_sub_id field.
|
||||
// Also verifies: parent callback_confirm=No (callback queued), sub-order callback_confirm=Ok.
|
||||
func TestOrderProcessingSubOrderPaidParentKeepsOwnFields(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_sub_pay_test", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
// Snapshot the parent's original fields before any payment.
|
||||
originalParent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("load original parent: %v", err)
|
||||
}
|
||||
|
||||
subResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch network create sub-order: %v", err)
|
||||
}
|
||||
|
||||
// Load the sub-order to get its DB primary-key ID.
|
||||
subOrder, err := data.GetOrderInfoByTradeId(subResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("load sub-order: %v", err)
|
||||
}
|
||||
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subResp.Token),
|
||||
Network: strings.ToLower(subResp.Network),
|
||||
TradeId: subResp.TradeId,
|
||||
Amount: subResp.ActualAmount,
|
||||
BlockTransactionId: "block_sub_paid",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("order processing sub-order: %v", err)
|
||||
}
|
||||
|
||||
// Sub-order must be paid with the block hash and no pending callback.
|
||||
sub, err := data.GetOrderInfoByTradeId(subResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload sub-order: %v", err)
|
||||
}
|
||||
if sub.Status != mdb.StatusPaySuccess {
|
||||
t.Fatalf("sub-order status = %d, want %d", sub.Status, mdb.StatusPaySuccess)
|
||||
}
|
||||
if sub.BlockTransactionId != "block_sub_paid" {
|
||||
t.Fatalf("sub-order block_transaction_id = %q, want %q", sub.BlockTransactionId, "block_sub_paid")
|
||||
}
|
||||
if sub.CallBackConfirm != mdb.CallBackConfirmOk {
|
||||
t.Fatalf("sub-order callback_confirm = %d, want %d (no callback for sub-order)", sub.CallBackConfirm, mdb.CallBackConfirmOk)
|
||||
}
|
||||
|
||||
// Parent must be paid but its own fields must be unchanged.
|
||||
parent, err := data.GetOrderInfoByTradeId(parentResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload parent order: %v", err)
|
||||
}
|
||||
if parent.Status != mdb.StatusPaySuccess {
|
||||
t.Fatalf("parent status = %d, want %d", parent.Status, mdb.StatusPaySuccess)
|
||||
}
|
||||
if parent.BlockTransactionId != "" {
|
||||
t.Fatalf("parent block_transaction_id = %q, want empty (parent was not directly paid)", parent.BlockTransactionId)
|
||||
}
|
||||
if parent.ReceiveAddress != originalParent.ReceiveAddress {
|
||||
t.Fatalf("parent receive_address changed: got %q, want %q", parent.ReceiveAddress, originalParent.ReceiveAddress)
|
||||
}
|
||||
if parent.ActualAmount != originalParent.ActualAmount {
|
||||
t.Fatalf("parent actual_amount changed: got %v, want %v", parent.ActualAmount, originalParent.ActualAmount)
|
||||
}
|
||||
if parent.PayBySubId != subOrder.ID {
|
||||
t.Fatalf("parent pay_by_sub_id = %d, want %d (sub-order ID)", parent.PayBySubId, subOrder.ID)
|
||||
}
|
||||
if parent.CallBackConfirm != mdb.CallBackConfirmNo {
|
||||
t.Fatalf("parent callback_confirm = %d, want %d (callback must be queued)", parent.CallBackConfirm, mdb.CallBackConfirmNo)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks verifies that when one
|
||||
// sub-order is paid all sibling sub-orders are expired and their runtime locks (as
|
||||
// well as the parent's lock) are released.
|
||||
func TestOrderProcessingSubOrderExpiresSiblingsAndReleasesLocks(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if _, err := data.AddWalletAddress("TTestTronAddress001"); err != nil {
|
||||
t.Fatalf("add tron wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, "0xA1B2c3D4e5F60718293aBcDeF001122334455667"); err != nil {
|
||||
t.Fatalf("add ethereum wallet: %v", err)
|
||||
}
|
||||
if _, err := data.AddWalletAddressWithNetwork(mdb.NetworkBsc, "0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"); err != nil {
|
||||
t.Fatalf("add bsc wallet: %v", err)
|
||||
}
|
||||
|
||||
parentReq := newCreateTransactionRequest("order_sib_expiry_test", 1)
|
||||
parentReq.Network = mdb.NetworkTron
|
||||
parentResp, err := CreateTransaction(parentReq, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("create parent order: %v", err)
|
||||
}
|
||||
|
||||
// Create two sub-orders on different networks.
|
||||
subEthResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkEthereum,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch to ethereum sub-order: %v", err)
|
||||
}
|
||||
|
||||
subBscResp, err := SwitchNetwork(&request.SwitchNetworkRequest{
|
||||
TradeId: parentResp.TradeId,
|
||||
Token: "usdt",
|
||||
Network: mdb.NetworkBsc,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("switch to bsc sub-order: %v", err)
|
||||
}
|
||||
|
||||
// Pay the Ethereum sub-order.
|
||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||
ReceiveAddress: subEthResp.ReceiveAddress,
|
||||
Token: strings.ToUpper(subEthResp.Token),
|
||||
Network: strings.ToLower(subEthResp.Network),
|
||||
TradeId: subEthResp.TradeId,
|
||||
Amount: subEthResp.ActualAmount,
|
||||
BlockTransactionId: "block_sib_eth",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("order processing eth sub-order: %v", err)
|
||||
}
|
||||
|
||||
// BSC sibling must be expired.
|
||||
subBsc, err := data.GetOrderInfoByTradeId(subBscResp.TradeId)
|
||||
if err != nil {
|
||||
t.Fatalf("reload bsc sub-order: %v", err)
|
||||
}
|
||||
if subBsc.Status != mdb.StatusExpired {
|
||||
t.Fatalf("bsc sibling status = %d, want %d (expired)", subBsc.Status, mdb.StatusExpired)
|
||||
}
|
||||
|
||||
// Parent runtime lock must be released.
|
||||
parentLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||
mdb.NetworkTron, parentResp.ReceiveAddress, parentResp.Token, parentResp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("check parent runtime lock: %v", err)
|
||||
}
|
||||
if parentLock != "" {
|
||||
t.Fatalf("parent runtime lock still held: trade_id=%s", parentLock)
|
||||
}
|
||||
|
||||
// BSC sibling runtime lock must be released.
|
||||
sibLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||
mdb.NetworkBsc, subBscResp.ReceiveAddress, subBscResp.Token, subBscResp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("check bsc sibling runtime lock: %v", err)
|
||||
}
|
||||
if sibLock != "" {
|
||||
t.Fatalf("bsc sibling runtime lock still held: trade_id=%s", sibLock)
|
||||
}
|
||||
|
||||
// Ethereum sub-order runtime lock must also be released.
|
||||
ethLock, err := data.GetTradeIdByWalletAddressAndAmountAndToken(
|
||||
mdb.NetworkEthereum, subEthResp.ReceiveAddress, subEthResp.Token, subEthResp.ActualAmount)
|
||||
if err != nil {
|
||||
t.Fatalf("check eth sub-order runtime lock: %v", err)
|
||||
}
|
||||
if ethLock != "" {
|
||||
t.Fatalf("eth sub-order runtime lock still held: trade_id=%s", ethLock)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/response"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/response"
|
||||
)
|
||||
|
||||
var ErrOrder = errors.New("不存在待支付订单或已过期")
|
||||
|
||||
@@ -10,13 +10,13 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/assimon/luuu/util/math"
|
||||
"github.com/GMWalletApp/epusdt/config"
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/math"
|
||||
"github.com/gagliardetto/solana-go"
|
||||
"github.com/go-resty/resty/v2"
|
||||
"github.com/shopspring/decimal"
|
||||
@@ -45,6 +45,33 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
||||
}
|
||||
}()
|
||||
|
||||
// Load enabled Solana tokens from chain_tokens. The contract_address
|
||||
// column holds the mint for SPL tokens; a special entry with
|
||||
// symbol=SOL and empty contract_address enables native SOL payments.
|
||||
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkSolana)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] load chain_tokens err=%v", address, err)
|
||||
return
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
log.Sugar.Debugf("[SOL][%s] no enabled chain_tokens, skipping", address)
|
||||
return
|
||||
}
|
||||
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 == "" {
|
||||
continue
|
||||
}
|
||||
mintTokens[mint] = &tokens[i]
|
||||
}
|
||||
|
||||
// Clean up old entries from processed cache (older than 1 hour)
|
||||
cleanupCutoff := time.Now().Add(-1 * time.Hour).Unix()
|
||||
gProcessedSignatures.Range(func(key, value interface{}) bool {
|
||||
@@ -56,17 +83,15 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
||||
|
||||
limit := 1000
|
||||
|
||||
// 查询钱包地址 + USDT ATA + USDC ATA 三个地址的签名
|
||||
// 查询钱包地址 + 每个已启用 SPL 代币 ATA 的签名
|
||||
queryAddrs := []string{address}
|
||||
if usdtAta, err := FindATAAddress(address, USDT_Mint); err == nil {
|
||||
queryAddrs = append(queryAddrs, usdtAta)
|
||||
} else {
|
||||
log.Sugar.Errorf("[SOL][%s] failed to derive USDT ATA: %v", address, err)
|
||||
}
|
||||
if usdcAta, err := FindATAAddress(address, USDC_Mint); err == nil {
|
||||
queryAddrs = append(queryAddrs, usdcAta)
|
||||
} else {
|
||||
log.Sugar.Errorf("[SOL][%s] failed to derive USDC ATA: %v", address, err)
|
||||
for mint := range mintTokens {
|
||||
ata, err := FindATAAddress(address, mint)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[SOL][%s] failed to derive ATA for mint %s: %v", address, mint, err)
|
||||
continue
|
||||
}
|
||||
queryAddrs = append(queryAddrs, ata)
|
||||
}
|
||||
|
||||
// 拉取签名并去重
|
||||
@@ -177,7 +202,7 @@ func SolCallBack(address string, wg *sync.WaitGroup) {
|
||||
continue
|
||||
}
|
||||
|
||||
token, amount := getTokenTypeAndAmount(transferInfo)
|
||||
token, amount := resolveSolTokenAndAmount(transferInfo, mintTokens, nativeSolToken)
|
||||
if token == "" || amount <= 0 {
|
||||
continue
|
||||
}
|
||||
@@ -256,6 +281,21 @@ type solSignatureResult struct {
|
||||
BlockTime *int64 `json:"blockTime"`
|
||||
}
|
||||
|
||||
func resolveSolanaRpcURL() (string, error) {
|
||||
node, err := data.SelectRpcNode(mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if node == nil || node.ID == 0 {
|
||||
return "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkSolana, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
rpcURL := strings.TrimSpace(node.Url)
|
||||
if rpcURL == "" {
|
||||
return "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||
}
|
||||
return rpcURL, nil
|
||||
}
|
||||
|
||||
// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
|
||||
func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
||||
client := resty.New()
|
||||
@@ -272,7 +312,10 @@ func SolRetryClient(method string, params []interface{}) ([]byte, error) {
|
||||
return false
|
||||
})
|
||||
|
||||
rpcUrl := config.GetSolanaRpcUrl()
|
||||
rpcUrl, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := client.R().
|
||||
SetHeader("Content-Type", "application/json").
|
||||
@@ -363,35 +406,42 @@ func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
|
||||
return MatchAtaAddress(targetAddress, transfer.Mint, transfer.Destination)
|
||||
}
|
||||
|
||||
// getTokenTypeAndAmount 根据 mint 识别代币类型并计算可读金额
|
||||
func getTokenTypeAndAmount(transfer *TransferInfo) (string, float64) {
|
||||
// resolveSolTokenAndAmount identifies the token from a transfer using
|
||||
// the admin-configured chain_tokens list. mintTokens maps SPL mint
|
||||
// addresses to their ChainToken config; nativeSolToken is non-nil when a
|
||||
// native SOL entry exists in chain_tokens. Returns ("", 0) for
|
||||
// transfers whose mint is not configured or below min_amount.
|
||||
func resolveSolTokenAndAmount(transfer *TransferInfo, mintTokens map[string]*mdb.ChainToken, nativeSolToken *mdb.ChainToken) (string, float64) {
|
||||
mint := transfer.Mint
|
||||
|
||||
// Native SOL
|
||||
if mint == "SOL" {
|
||||
return "SOL", transfer.Amount
|
||||
if nativeSolToken == nil {
|
||||
return "", 0
|
||||
}
|
||||
amount := transfer.Amount
|
||||
if nativeSolToken.MinAmount > 0 && amount < nativeSolToken.MinAmount {
|
||||
return "", 0
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(nativeSolToken.Symbol)), amount
|
||||
}
|
||||
|
||||
// SPL Tokens
|
||||
switch mint {
|
||||
case USDT_Mint:
|
||||
decimals := USDT_Decimals
|
||||
if transfer.Decimals != nil {
|
||||
decimals = int(*transfer.Decimals)
|
||||
}
|
||||
return "USDT", ADJustAmount(transfer.RawAmount, decimals)
|
||||
|
||||
case USDC_Mint:
|
||||
decimals := USDC_Decimals
|
||||
if transfer.Decimals != nil {
|
||||
decimals = int(*transfer.Decimals)
|
||||
}
|
||||
return "USDC", ADJustAmount(transfer.RawAmount, decimals)
|
||||
|
||||
default:
|
||||
// Unsupported token
|
||||
cfg, ok := mintTokens[mint]
|
||||
if !ok || cfg == nil {
|
||||
return "", 0
|
||||
}
|
||||
decimals := cfg.Decimals
|
||||
if transfer.Decimals != nil {
|
||||
decimals = int(*transfer.Decimals)
|
||||
}
|
||||
if decimals <= 0 {
|
||||
decimals = 6
|
||||
}
|
||||
amount := ADJustAmount(transfer.RawAmount, decimals)
|
||||
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
|
||||
return "", 0
|
||||
}
|
||||
return strings.ToUpper(strings.TrimSpace(cfg.Symbol)), amount
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -437,7 +487,7 @@ func ADJustAmount(amount uint64, decimals int) float64 {
|
||||
func MatchUsdtAtaAddress(address string, ataTo string) bool {
|
||||
ata, err := FindATAAddress(address, USDT_Mint)
|
||||
if err != nil {
|
||||
fmt.Printf("FindATAAddress failed: %v\n", err)
|
||||
log.Sugar.Errorf("FindATAAddress failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -447,7 +497,7 @@ func MatchUsdtAtaAddress(address string, ataTo string) bool {
|
||||
func MatchUsdcAtaAddress(address string, ataTo string) bool {
|
||||
ata, err := FindATAAddress(address, USDC_Mint)
|
||||
if err != nil {
|
||||
fmt.Printf("FindATAAddress failed: %v\n", err)
|
||||
log.Sugar.Errorf("FindATAAddress failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -457,7 +507,7 @@ func MatchUsdcAtaAddress(address string, ataTo string) bool {
|
||||
func MatchAtaAddress(address string, mint string, ataTo string) bool {
|
||||
ata, err := FindATAAddress(address, mint)
|
||||
if err != nil {
|
||||
fmt.Printf("FindATAAddress failed: %v\n", err)
|
||||
log.Sugar.Errorf("FindATAAddress failed: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,59 @@ package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func setupSolanaRPCNode(t *testing.T, url string) func() {
|
||||
t.Helper()
|
||||
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
node := &mdb.RpcNode{
|
||||
Network: mdb.NetworkSolana,
|
||||
Url: url,
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
cleanup()
|
||||
t.Fatalf("seed solana rpc_node: %v", err)
|
||||
}
|
||||
return cleanup
|
||||
}
|
||||
|
||||
func TestResolveSolanaRpcURLRequiresRpcNode(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if got, err := resolveSolanaRpcURL(); err == nil {
|
||||
t.Fatalf("resolveSolanaRpcURL() = %q, nil; want error", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSolanaRpcURLWithRow(t *testing.T) {
|
||||
cleanup := setupSolanaRPCNode(t, " https://solana.example.com ")
|
||||
defer cleanup()
|
||||
|
||||
got, err := resolveSolanaRpcURL()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSolanaRpcURL(): %v", err)
|
||||
}
|
||||
if got != "https://solana.example.com" {
|
||||
t.Fatalf("resolveSolanaRpcURL() = %q, want https://solana.example.com", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSolClientHealthy(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
bodyData, err := SolRetryClient("getHealth", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient failed: %v", err)
|
||||
@@ -33,6 +79,8 @@ func TestSolClientHealthy(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSolClientGetSignaturesForAddress(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// Example wallet address (replace with actual test address)
|
||||
address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
||||
|
||||
@@ -57,6 +105,8 @@ func TestSolClientGetSignaturesForAddress(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSolClientGetTransaction(t *testing.T) {
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// Example transaction signature (replace with actual test signature)
|
||||
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
|
||||
|
||||
@@ -64,7 +114,6 @@ func TestSolClientGetTransaction(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("SolRetryClient failed: %v", err)
|
||||
}
|
||||
fmt.Printf("%v\n", string(txData))
|
||||
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(txData, &result)
|
||||
@@ -80,6 +129,15 @@ func TestSolClientGetTransaction(t *testing.T) {
|
||||
t.Logf("Transaction Info for signature %s: %v", sig, txInfo)
|
||||
}
|
||||
|
||||
func requireSolanaIntegration(t *testing.T) {
|
||||
t.Helper()
|
||||
if testing.Short() || os.Getenv("RUN_SOLANA_INTEGRATION_TESTS") == "" {
|
||||
t.Skip("set RUN_SOLANA_INTEGRATION_TESTS=1 to run Solana public RPC integration tests")
|
||||
}
|
||||
cleanup := setupSolanaRPCNode(t, "https://api.mainnet-beta.solana.com")
|
||||
t.Cleanup(cleanup)
|
||||
}
|
||||
|
||||
func TestFindATAAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -219,9 +277,7 @@ func TestAdjustAmount(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances)
|
||||
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
|
||||
@@ -261,9 +317,7 @@ func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// SPL Token "transferChecked" (has mint and tokenAmount in instruction)
|
||||
sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
|
||||
@@ -306,9 +360,7 @@ func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test")
|
||||
}
|
||||
requireSolanaIntegration(t)
|
||||
|
||||
// System program SOL transfer
|
||||
sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ"
|
||||
|
||||
@@ -3,20 +3,36 @@ package service
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/assimon/luuu/model/data"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
"github.com/assimon/luuu/model/request"
|
||||
"github.com/assimon/luuu/telegram"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
"github.com/assimon/luuu/util/math"
|
||||
"math/big"
|
||||
"strings"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/model/data"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
"github.com/GMWalletApp/epusdt/model/request"
|
||||
"github.com/GMWalletApp/epusdt/notify"
|
||||
"github.com/GMWalletApp/epusdt/util/constant"
|
||||
"github.com/GMWalletApp/epusdt/util/log"
|
||||
"github.com/GMWalletApp/epusdt/util/math"
|
||||
"github.com/dromara/carbon/v2"
|
||||
"github.com/ethereum/go-ethereum/common"
|
||||
"github.com/shopspring/decimal"
|
||||
"math/big"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func resolveTronNode() (string, string, error) {
|
||||
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if node == nil || node.ID == 0 {
|
||||
return "", "", fmt.Errorf("no enabled %s %s RPC node configured in rpc_nodes", mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
}
|
||||
rpcURL := strings.TrimRight(strings.TrimSpace(node.Url), "/")
|
||||
if rpcURL == "" {
|
||||
return "", "", fmt.Errorf("rpc_nodes id=%d has empty url", node.ID)
|
||||
}
|
||||
return rpcURL, node.ApiKey, nil
|
||||
}
|
||||
|
||||
func TryProcessTronTRC20Transfer(toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
@@ -277,7 +293,7 @@ func sendPaymentNotification(order *mdb.Orders) {
|
||||
order.CreatedAt.ToDateTimeString(),
|
||||
carbon.Now().ToDateTimeString(),
|
||||
)
|
||||
telegram.SendToBot(msg)
|
||||
notify.Dispatch(mdb.NotifyEventPaySuccess, msg)
|
||||
}
|
||||
|
||||
func networkDisplay(n string) string {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/GMWalletApp/epusdt/internal/testutil"
|
||||
"github.com/GMWalletApp/epusdt/model/dao"
|
||||
"github.com/GMWalletApp/epusdt/model/mdb"
|
||||
)
|
||||
|
||||
// TestResolveTronNode_NoRow verifies that resolveTronNode requires an enabled
|
||||
// TRON row from rpc_nodes.
|
||||
func TestResolveTronNode_NoRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
if gotURL, gotKey, err := resolveTronNode(); err == nil {
|
||||
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTronNode_WithRow verifies that resolveTronNode uses the DB row
|
||||
// when an enabled TRON HTTP node is present.
|
||||
func TestResolveTronNode_WithRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
// Insert an enabled TRON http node.
|
||||
node := &mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://custom-tron.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
ApiKey: "db-api-key",
|
||||
Weight: 1,
|
||||
Enabled: true,
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed rpc_node: %v", err)
|
||||
}
|
||||
|
||||
gotURL, gotKey, err := resolveTronNode()
|
||||
if err != nil {
|
||||
t.Fatalf("resolveTronNode(): %v", err)
|
||||
}
|
||||
if gotURL != "https://custom-tron.example.com" {
|
||||
t.Errorf("url = %q, want https://custom-tron.example.com", gotURL)
|
||||
}
|
||||
if gotKey != "db-api-key" {
|
||||
t.Errorf("apiKey = %q, want \"db-api-key\"", gotKey)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveTronNode_DisabledRow verifies that a disabled row is ignored.
|
||||
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
node := &mdb.RpcNode{
|
||||
Network: mdb.NetworkTron,
|
||||
Url: "https://disabled-tron.example.com",
|
||||
Type: mdb.RpcNodeTypeHttp,
|
||||
ApiKey: "disabled-key",
|
||||
Weight: 1,
|
||||
Enabled: true, // start enabled, then disable below
|
||||
Status: mdb.RpcNodeStatusOk,
|
||||
}
|
||||
if err := dao.Mdb.Create(node).Error; err != nil {
|
||||
t.Fatalf("seed rpc_node: %v", err)
|
||||
}
|
||||
// Explicitly disable — GORM does not save bool zero-value on Create.
|
||||
if err := dao.Mdb.Model(node).Update("enabled", false).Error; err != nil {
|
||||
t.Fatalf("disable rpc_node: %v", err)
|
||||
}
|
||||
|
||||
if gotURL, gotKey, err := resolveTronNode(); err == nil {
|
||||
t.Fatalf("resolveTronNode() = (%q, %q, nil), want error", gotURL, gotKey)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user