mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 18:26:16 +00:00
feat: add admin panel with multi-chain support and notification system
- Add full admin REST API: auth, API keys, chains, chain tokens, wallets, orders, RPC nodes, settings, dashboard stats, notifications - Add JWT-based admin authentication and API key auth middleware - Add multi-chain listeners: BSC, ETH, Polygon, Plasma, EVM WebSocket - Add RPC node health check job with automatic failover - Add Telegram notification channel for order events - Add installer for first-run database initialization - Add new DB models: admin_user, api_key, chain, chain_token, rpc_node, settings, notification_channel - Add order statistics aggregation (daily/monthly/by-chain) - Serve built admin SPA from embedded www/ assets - Add comprehensive test coverage for all new features
This commit is contained in:
@@ -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,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
@@ -32,11 +33,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 +72,49 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 +123,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 +171,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 +213,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 +259,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 +347,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -363,35 +388,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 +469,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 +479,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 +489,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
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"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/notify"
|
||||
"github.com/assimon/luuu/util/constant"
|
||||
"github.com/assimon/luuu/util/http_client"
|
||||
"github.com/assimon/luuu/util/log"
|
||||
@@ -26,7 +26,20 @@ import (
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
|
||||
// tronNodeDefault is the fallback when no rpc_nodes row exists for TRON.
|
||||
const tronNodeDefault = "https://api.trongrid.io"
|
||||
|
||||
// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
|
||||
// It reads the first healthy (or any enabled) row from the rpc_nodes table;
|
||||
// if none exists it falls back to the hard-coded default URL and the
|
||||
// tron_grid_api_key value from .env (config.TRON_GRID_API_KEY).
|
||||
func resolveTronNode() (string, string) {
|
||||
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
|
||||
if err == nil && node != nil && node.ID > 0 {
|
||||
return strings.TrimRight(node.Url, "/"), node.ApiKey
|
||||
}
|
||||
return tronNodeDefault, config.TRON_GRID_API_KEY
|
||||
}
|
||||
|
||||
func Trc20CallBack(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
@@ -45,16 +58,29 @@ func Trc20CallBack(address string, wg *sync.WaitGroup) {
|
||||
|
||||
func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] panic recovered: %v", address, err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Native TRX is gated by a chain_tokens row with empty
|
||||
// contract_address and symbol=TRX. Admin can disable this row to
|
||||
// stop scanning native transfers without touching the chain toggle.
|
||||
trxCfg, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, "TRX")
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRX][%s] load chain_tokens err=%v", address, err)
|
||||
return
|
||||
}
|
||||
if trxCfg == nil || trxCfg.ID == 0 {
|
||||
log.Sugar.Debugf("[TRX][%s] native TRX disabled in chain_tokens, skipping", address)
|
||||
return
|
||||
}
|
||||
trxDecimals := trxCfg.Decimals
|
||||
if trxDecimals <= 0 {
|
||||
trxDecimals = 6
|
||||
}
|
||||
|
||||
client := http_client.GetHttpClient()
|
||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||
endTime := carbon.Now().TimestampMilli()
|
||||
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address)
|
||||
tronBaseURL, tronAPIKey := resolveTronNode()
|
||||
url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, address)
|
||||
|
||||
resp, err := client.R().SetQueryParams(map[string]string{
|
||||
"order_by": "block_timestamp,desc",
|
||||
@@ -62,17 +88,20 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
||||
"only_to": "true",
|
||||
"min_timestamp": stdutil.ToString(startTime),
|
||||
"max_timestamp": stdutil.ToString(endTime),
|
||||
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
|
||||
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Sugar.Errorf("[TRX][%s] HTTP request failed: %v", address, err)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode()))
|
||||
log.Sugar.Errorf("[TRX][%s] API returned status %d", address, resp.StatusCode())
|
||||
return
|
||||
}
|
||||
|
||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
||||
if !success {
|
||||
panic("TRX API response indicates failure")
|
||||
log.Sugar.Errorf("[TRX][%s] API response indicates failure", address)
|
||||
return
|
||||
}
|
||||
|
||||
transfers := gjson.GetBytes(resp.Body(), "data").Array()
|
||||
@@ -106,15 +135,19 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
||||
log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err)
|
||||
continue
|
||||
}
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1000000)).InexactFloat64(), 2)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(trxDecimals))).InexactFloat64(), 2)
|
||||
if trxCfg.MinAmount > 0 && amount < trxCfg.MinAmount {
|
||||
continue
|
||||
}
|
||||
if amount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
txID := transfer.Get("txID").String()
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "TRX", amount)
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)), amount)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Sugar.Errorf("[TRX][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
|
||||
continue
|
||||
}
|
||||
if tradeID == "" {
|
||||
log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
|
||||
@@ -124,7 +157,8 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Sugar.Errorf("[TRX][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
|
||||
continue
|
||||
}
|
||||
blockTimestamp := transfer.Get("block_timestamp").Int()
|
||||
createTime := order.CreatedAt.TimestampMilli()
|
||||
@@ -135,7 +169,7 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: address,
|
||||
Token: "TRX",
|
||||
Token: strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)),
|
||||
Network: mdb.NetworkTron,
|
||||
TradeId: tradeID,
|
||||
Amount: amount,
|
||||
@@ -147,7 +181,8 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
||||
log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
||||
continue
|
||||
}
|
||||
panic(err)
|
||||
log.Sugar.Errorf("[TRX][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
sendPaymentNotification(order)
|
||||
@@ -157,16 +192,33 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
||||
|
||||
func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Sugar.Errorf("[TRC20][%s] panic recovered: %v", address, err)
|
||||
|
||||
// Build contract -> token map for the TRON network. If nothing is
|
||||
// configured, skip — preserves the previous behavior of only watching
|
||||
// admin-approved tokens.
|
||||
tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTron)
|
||||
if err != nil {
|
||||
log.Sugar.Errorf("[TRC20][%s] load chain_tokens err=%v", address, err)
|
||||
return
|
||||
}
|
||||
if len(tokens) == 0 {
|
||||
log.Sugar.Debugf("[TRC20][%s] no enabled chain_tokens, skipping", address)
|
||||
return
|
||||
}
|
||||
contractTokens := make(map[string]*mdb.ChainToken, len(tokens))
|
||||
for i := range tokens {
|
||||
c := strings.TrimSpace(tokens[i].ContractAddress)
|
||||
if c == "" {
|
||||
continue
|
||||
}
|
||||
}()
|
||||
contractTokens[c] = &tokens[i]
|
||||
}
|
||||
|
||||
client := http_client.GetHttpClient()
|
||||
startTime := carbon.Now().AddHours(-24).TimestampMilli()
|
||||
endTime := carbon.Now().TimestampMilli()
|
||||
url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address)
|
||||
tronBaseURL, tronAPIKey := resolveTronNode()
|
||||
url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, address)
|
||||
|
||||
resp, err := client.R().SetQueryParams(map[string]string{
|
||||
"order_by": "block_timestamp,desc",
|
||||
@@ -174,17 +226,20 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
||||
"only_to": "true",
|
||||
"min_timestamp": stdutil.ToString(startTime),
|
||||
"max_timestamp": stdutil.ToString(endTime),
|
||||
}).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url)
|
||||
}).SetHeader("TRON-PRO-API-KEY", tronAPIKey).Get(url)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Sugar.Errorf("[TRC20][%s] HTTP request failed: %v", address, err)
|
||||
return
|
||||
}
|
||||
if resp.StatusCode() != http.StatusOK {
|
||||
panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode()))
|
||||
log.Sugar.Errorf("[TRC20][%s] API returned status %d", address, resp.StatusCode())
|
||||
return
|
||||
}
|
||||
|
||||
success := gjson.GetBytes(resp.Body(), "success").Bool()
|
||||
if !success {
|
||||
panic("TRC20 API response indicates failure")
|
||||
log.Sugar.Errorf("[TRC20][%s] API response indicates failure", address)
|
||||
return
|
||||
}
|
||||
|
||||
transfers := gjson.GetBytes(resp.Body(), "data").Array()
|
||||
@@ -195,12 +250,15 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
||||
log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
|
||||
|
||||
for i, transfer := range transfers {
|
||||
if transfer.Get("token_info.address").String() != TRC20_USDT_ID {
|
||||
contractAddr := transfer.Get("token_info.address").String()
|
||||
cfg, ok := contractTokens[contractAddr]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if transfer.Get("to").String() != address {
|
||||
continue
|
||||
}
|
||||
tokenSym := strings.ToUpper(strings.TrimSpace(cfg.Symbol))
|
||||
|
||||
valueStr := transfer.Get("value").String()
|
||||
decimalQuant, err := decimal.NewFromString(valueStr)
|
||||
@@ -209,25 +267,33 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
||||
continue
|
||||
}
|
||||
tokenDecimals := transfer.Get("token_info.decimals").Int()
|
||||
if tokenDecimals <= 0 {
|
||||
tokenDecimals = int64(cfg.Decimals)
|
||||
}
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
|
||||
if cfg.MinAmount > 0 && amount < cfg.MinAmount {
|
||||
continue
|
||||
}
|
||||
if amount <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
txID := transfer.Get("transaction_id").String()
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "USDT", amount)
|
||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, tokenSym, amount)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if tradeID == "" {
|
||||
log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
|
||||
log.Sugar.Errorf("[TRC20][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Infof("[TRC20][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
|
||||
if tradeID == "" {
|
||||
log.Sugar.Debugf("[TRC20][%s] skip unmatched %s tx hash=%s amount=%.2f", address, tokenSym, txID, amount)
|
||||
continue
|
||||
}
|
||||
log.Sugar.Infof("[TRC20][%s] matched %s trade_id=%s hash=%s amount=%.2f", address, tokenSym, tradeID, txID, amount)
|
||||
|
||||
order, err := data.GetOrderInfoByTradeId(tradeID)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
log.Sugar.Errorf("[TRC20][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
|
||||
continue
|
||||
}
|
||||
blockTimestamp := transfer.Get("block_timestamp").Int()
|
||||
createTime := order.CreatedAt.TimestampMilli()
|
||||
@@ -238,7 +304,7 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: address,
|
||||
Token: "USDT",
|
||||
Token: tokenSym,
|
||||
Network: mdb.NetworkTron,
|
||||
TradeId: tradeID,
|
||||
Amount: amount,
|
||||
@@ -250,7 +316,8 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
||||
log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
||||
continue
|
||||
}
|
||||
panic(err)
|
||||
log.Sugar.Errorf("[TRC20][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
sendPaymentNotification(order)
|
||||
@@ -273,7 +340,9 @@ func evmChainLogLabel(chainNetwork string) string {
|
||||
}
|
||||
}
|
||||
|
||||
// TryProcessEvmERC20Transfer 处理各 EVM 链上 USDT/USDC(及 Polygon USDC.e)的 Transfer 入账(合约与 network 需一致)。
|
||||
// TryProcessEvmERC20Transfer 处理各 EVM 链上代币的 Transfer 入账。
|
||||
// 代币识别、符号和 decimals 全部从 chain_tokens 表动态查询 —
|
||||
// 管理后台新增 token 即可立即生效,无需代码改动。
|
||||
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
@@ -281,46 +350,38 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
|
||||
}
|
||||
}()
|
||||
|
||||
var usdt, usdc common.Address
|
||||
var polygonUsdcE common.Address
|
||||
switch chainNetwork {
|
||||
case mdb.NetworkEthereum:
|
||||
usdt = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
|
||||
usdc = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
|
||||
case mdb.NetworkBsc:
|
||||
usdt = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
|
||||
usdc = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
|
||||
case mdb.NetworkPolygon:
|
||||
usdt = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F")
|
||||
usdc = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359")
|
||||
polygonUsdcE = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
|
||||
case mdb.NetworkPlasma:
|
||||
// USDT0(官方),6 decimals;链上暂无与 ETH 同级的 Circle USDC 部署,仅匹配 USDT 订单
|
||||
usdt = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
var tokenSym string
|
||||
switch {
|
||||
case contract == usdt:
|
||||
tokenSym = "USDT"
|
||||
case contract == usdc || (polygonUsdcE != (common.Address{}) && contract == polygonUsdcE):
|
||||
tokenSym = "USDC"
|
||||
default:
|
||||
net := evmChainLogLabel(chainNetwork)
|
||||
log.Sugar.Warnf("[%s-WS] skip unsupported contract %s", net, contract.Hex())
|
||||
return
|
||||
}
|
||||
|
||||
net := evmChainLogLabel(chainNetwork)
|
||||
token, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
|
||||
if err != nil {
|
||||
log.Sugar.Warnf("[%s-WS] chain_tokens lookup err=%v contract=%s", net, err, contract.Hex())
|
||||
return
|
||||
}
|
||||
if token == nil || token.ID == 0 {
|
||||
log.Sugar.Debugf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
|
||||
return
|
||||
}
|
||||
tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol))
|
||||
if tokenSym == "" {
|
||||
log.Sugar.Warnf("[%s-WS] chain_token id=%d has empty symbol", net, token.ID)
|
||||
return
|
||||
}
|
||||
decimals := token.Decimals
|
||||
if decimals <= 0 {
|
||||
decimals = 6
|
||||
}
|
||||
|
||||
walletAddr := strings.ToLower(toAddr.Hex())
|
||||
if rawValue == nil || rawValue.Sign() <= 0 {
|
||||
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
|
||||
return
|
||||
}
|
||||
divisor := decimal.New(1, int32(decimals))
|
||||
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
|
||||
amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
|
||||
if token.MinAmount > 0 && amount < token.MinAmount {
|
||||
log.Sugar.Debugf("[%s-%s][%s] skip amount %.2f below min_amount %.2f", net, tokenSym, walletAddr, amount, token.MinAmount)
|
||||
return
|
||||
}
|
||||
if amount <= 0 {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
|
||||
return
|
||||
@@ -351,6 +412,11 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
|
||||
return
|
||||
}
|
||||
if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
|
||||
log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block_time_ms=%d is before order created_ms=%d",
|
||||
net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
|
||||
return
|
||||
}
|
||||
|
||||
req := &request.OrderProcessingRequest{
|
||||
ReceiveAddress: walletAddr,
|
||||
@@ -399,7 +465,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,92 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/assimon/luuu/config"
|
||||
"github.com/assimon/luuu/internal/testutil"
|
||||
"github.com/assimon/luuu/model/dao"
|
||||
"github.com/assimon/luuu/model/mdb"
|
||||
)
|
||||
|
||||
// TestResolveTronNode_NoRow verifies that resolveTronNode falls back to the
|
||||
// hard-coded default when the rpc_nodes table has no TRON row.
|
||||
func TestResolveTronNode_NoRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
config.TRON_GRID_API_KEY = "fallback-key"
|
||||
|
||||
gotURL, gotKey := resolveTronNode()
|
||||
if gotURL != tronNodeDefault {
|
||||
t.Errorf("url = %q, want %q", gotURL, tronNodeDefault)
|
||||
}
|
||||
if gotKey != "fallback-key" {
|
||||
t.Errorf("apiKey = %q, want \"fallback-key\"", 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()
|
||||
|
||||
config.TRON_GRID_API_KEY = "fallback-key"
|
||||
|
||||
// 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 := resolveTronNode()
|
||||
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 and
|
||||
// the fallback is used.
|
||||
func TestResolveTronNode_DisabledRow(t *testing.T) {
|
||||
cleanup := testutil.SetupTestDatabases(t)
|
||||
defer cleanup()
|
||||
|
||||
config.TRON_GRID_API_KEY = "fallback-key"
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
gotURL, gotKey := resolveTronNode()
|
||||
if gotURL != tronNodeDefault {
|
||||
t.Errorf("url = %q, want %q (disabled row should be ignored)", gotURL, tronNodeDefault)
|
||||
}
|
||||
if gotKey != "fallback-key" {
|
||||
t.Errorf("apiKey = %q, want \"fallback-key\"", gotKey)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user