This commit is contained in:
Ginta
2026-04-26 23:17:08 +08:00
292 changed files with 11193 additions and 1435 deletions
+80 -33
View File
@@ -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()
+417 -13
View File
@@ -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)
}
}
+4 -4
View File
@@ -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("不存在待支付订单或已过期")
+93 -43
View File
@@ -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
}
+63 -11
View File
@@ -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"
+26 -10
View File
@@ -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 {
+79
View File
@@ -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)
}
}