From 3f071e6c01813d21e8f74f07df72051433319ab8 Mon Sep 17 00:00:00 2001
From: line-6000 <154492442+line-6000@users.noreply.github.com>
Date: Sat, 11 Apr 2026 00:10:54 +0800
Subject: [PATCH] feat: add Solana payment scanning with USDT/USDC support and
wallet management API - Rewrite Solana scanner: stateless scan with
processed-signature cache, query wallet + USDT ATA + USDC ATA addresses to
cover all SPL transfers - Add network field to transaction_lock (4-tuple
unique index) and order flow - Add wallet management endpoints
(add/list/get/status/delete) with token auth - Add network info to Telegram
payment notification - Add USDC hint to test script GMPay prompt - Add
API docs and Solana scanning docs
---
src/.env.example | 1 +
src/config/config.go | 8 +
src/controller/comm/wallet_controller.go | 98 ++++
src/middleware/check_token.go | 24 +
src/model/dao/runtime_sqlite.go | 3 +
src/model/data/order_data.go | 18 +-
src/model/data/wallet_address_data.go | 43 +-
src/model/mdb/transaction_lock_mdb.go | 7 +-
src/model/mdb/wallet_address_mdb.go | 5 +
src/model/service/order_service.go | 20 +-
src/model/service/order_service_test.go | 11 +-
src/model/service/sol_task.go | 716 +++++++++++++++++------
src/model/service/sol_task_test.go | 358 +++++++++++-
src/model/service/task_service.go | 22 +-
src/mq/worker.go | 5 +-
src/mq/worker_test.go | 10 +-
src/route/router.go | 9 +-
src/route/router_test.go | 403 +++++++++++++
src/task/listen.go | 7 +
src/task/listen_sol_job.go | 38 ++
src/task/listen_trc20_job.go | 6 +-
21 files changed, 1579 insertions(+), 233 deletions(-)
create mode 100644 src/controller/comm/wallet_controller.go
create mode 100644 src/middleware/check_token.go
create mode 100644 src/route/router_test.go
create mode 100644 src/task/listen_sol_job.go
diff --git a/src/.env.example b/src/.env.example
index dbaf8fa..213edc3 100644
--- a/src/.env.example
+++ b/src/.env.example
@@ -61,3 +61,4 @@ order_notice_max_retry=0
forced_usdt_rate=
api_rate_url=
tron_grid_api_key=
+solana_rpc_url=
diff --git a/src/config/config.go b/src/config/config.go
index fb221d1..8f181e2 100644
--- a/src/config/config.go
+++ b/src/config/config.go
@@ -328,3 +328,11 @@ func GetCallbackRetryBaseDuration() time.Duration {
}
return time.Duration(seconds) * time.Second
}
+
+func GetSolanaRpcUrl() string {
+ rpcUrl := viper.GetString("solana_rpc_url")
+ if rpcUrl == "" {
+ return "https://api.mainnet-beta.solana.com"
+ }
+ return rpcUrl
+}
diff --git a/src/controller/comm/wallet_controller.go b/src/controller/comm/wallet_controller.go
new file mode 100644
index 0000000..71ca5a7
--- /dev/null
+++ b/src/controller/comm/wallet_controller.go
@@ -0,0 +1,98 @@
+package comm
+
+import (
+ "strconv"
+
+ "github.com/assimon/luuu/model/data"
+ "github.com/assimon/luuu/model/mdb"
+ "github.com/assimon/luuu/util/constant"
+ "github.com/labstack/echo/v4"
+)
+
+type addWalletRequest struct {
+ Network string `json:"network" validate:"required"`
+ Address string `json:"address" validate:"required"`
+}
+
+type changeStatusRequest struct {
+ Status int `json:"status" validate:"required|in:1,2"`
+}
+
+// AddWallet 添加钱包地址
+func (c *BaseCommController) AddWallet(ctx echo.Context) error {
+ req := new(addWalletRequest)
+ if err := ctx.Bind(req); err != nil {
+ return c.FailJson(ctx, constant.ParamsMarshalErr)
+ }
+ if err := c.ValidateStruct(ctx, req); err != nil {
+ return c.FailJson(ctx, err)
+ }
+ wallet, err := data.AddWalletAddressWithNetwork(req.Network, req.Address)
+ if err != nil {
+ return c.FailJson(ctx, err)
+ }
+ return c.SucJson(ctx, wallet)
+}
+
+// ListWallets 获取钱包列表
+func (c *BaseCommController) ListWallets(ctx echo.Context) error {
+ network := ctx.QueryParam("network")
+ var wallets []mdb.WalletAddress
+ var err error
+ if network != "" {
+ wallets, err = data.GetAllWalletAddressByNetwork(network)
+ } else {
+ wallets, err = data.GetAllWalletAddress()
+ }
+ if err != nil {
+ return c.FailJson(ctx, err)
+ }
+ return c.SucJson(ctx, wallets)
+}
+
+// GetWallet 获取单个钱包
+func (c *BaseCommController) GetWallet(ctx echo.Context) error {
+ id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
+ if err != nil {
+ return c.FailJson(ctx, constant.ParamsMarshalErr)
+ }
+ wallet, err := data.GetWalletAddressById(id)
+ if err != nil {
+ return c.FailJson(ctx, err)
+ }
+ if wallet.ID <= 0 {
+ return c.FailJson(ctx, constant.NotAvailableWalletAddress)
+ }
+ return c.SucJson(ctx, wallet)
+}
+
+// ChangeWalletStatus 启用/禁用钱包
+func (c *BaseCommController) ChangeWalletStatus(ctx echo.Context) error {
+ id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
+ if err != nil {
+ return c.FailJson(ctx, constant.ParamsMarshalErr)
+ }
+ req := new(changeStatusRequest)
+ if err := ctx.Bind(req); err != nil {
+ return c.FailJson(ctx, constant.ParamsMarshalErr)
+ }
+ if err := c.ValidateStruct(ctx, req); err != nil {
+ return c.FailJson(ctx, err)
+ }
+ if err := data.ChangeWalletAddressStatus(id, req.Status); err != nil {
+ return c.FailJson(ctx, err)
+ }
+ return c.SucJson(ctx, nil)
+}
+
+// DeleteWallet 删除钱包
+func (c *BaseCommController) DeleteWallet(ctx echo.Context) error {
+ id, err := strconv.ParseUint(ctx.Param("id"), 10, 64)
+ if err != nil {
+ return c.FailJson(ctx, constant.ParamsMarshalErr)
+ }
+ if err := data.DeleteWalletAddressById(id); err != nil {
+ return c.FailJson(ctx, err)
+ }
+ return c.SucJson(ctx, nil)
+}
diff --git a/src/middleware/check_token.go b/src/middleware/check_token.go
new file mode 100644
index 0000000..a679736
--- /dev/null
+++ b/src/middleware/check_token.go
@@ -0,0 +1,24 @@
+package middleware
+
+import (
+ "github.com/assimon/luuu/config"
+ "github.com/assimon/luuu/util/constant"
+ "github.com/labstack/echo/v4"
+)
+
+// CheckApiToken validates the Authorization header against the configured api_auth_token.
+// Use this for management APIs (GET/POST) where body-based signature is not practical.
+func CheckApiToken() echo.MiddlewareFunc {
+ return func(next echo.HandlerFunc) echo.HandlerFunc {
+ return func(ctx echo.Context) error {
+ token := ctx.Request().Header.Get("Authorization")
+ if token == "" {
+ token = ctx.QueryParam("api_token")
+ }
+ if token == "" || token != config.GetApiAuthToken() {
+ return constant.SignatureErr
+ }
+ return next(ctx)
+ }
+ }
+}
diff --git a/src/model/dao/runtime_sqlite.go b/src/model/dao/runtime_sqlite.go
index 08a942b..291a518 100644
--- a/src/model/dao/runtime_sqlite.go
+++ b/src/model/dao/runtime_sqlite.go
@@ -41,6 +41,9 @@ func RuntimeInit() error {
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_token_amount_uindex").Error; err != nil {
return err
}
+ if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_address_token_amount_uindex").Error; err != nil {
+ return err
+ }
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
return err
diff --git a/src/model/data/order_data.go b/src/model/data/order_data.go
index daa03c8..b5a8e16 100644
--- a/src/model/data/order_data.go
+++ b/src/model/data/order_data.go
@@ -108,11 +108,12 @@ func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, e
return result.RowsAffected > 0, result.Error
}
-// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by address, token and amount.
-func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) {
+// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount.
+func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
scaledAmount, _ := normalizeLockAmount(amount)
var lock mdb.TransactionLock
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
+ Where("network = ?", network).
Where("address = ?", address).
Where("token = ?", normalizeLockToken(token)).
Where("amount_scaled = ?", scaledAmount).
@@ -128,12 +129,13 @@ func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, am
return lock.TradeId, nil
}
-// LockTransaction reserves an address+token+amount pair in sqlite until expiration.
-func LockTransaction(address, token, tradeID string, amount float64, expirationTime time.Duration) error {
+// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
+func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
scaledAmount, amountText := normalizeLockAmount(amount)
normalizedToken := normalizeLockToken(token)
now := time.Now()
lock := &mdb.TransactionLock{
+ Network: network,
Address: address,
Token: normalizedToken,
AmountScaled: scaledAmount,
@@ -143,7 +145,8 @@ func LockTransaction(address, token, tradeID string, amount float64, expirationT
}
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
- if err := tx.Where("address = ?", address).
+ if err := tx.Where("network = ?", network).
+ Where("address = ?", address).
Where("token = ?", normalizedToken).
Where("amount_scaled = ?", scaledAmount).
Where("expires_at <= ?", now).
@@ -165,10 +168,11 @@ func LockTransaction(address, token, tradeID string, amount float64, expirationT
})
}
-// UnLockTransaction releases the reservation for address+token+amount.
-func UnLockTransaction(address string, token string, amount float64) error {
+// UnLockTransaction releases the reservation for network+address+token+amount.
+func UnLockTransaction(network string, address string, token string, amount float64) error {
scaledAmount, _ := normalizeLockAmount(amount)
return dao.RuntimeDB.
+ Where("network = ?", network).
Where("address = ?", address).
Where("token = ?", normalizeLockToken(token)).
Where("amount_scaled = ?", scaledAmount).
diff --git a/src/model/data/wallet_address_data.go b/src/model/data/wallet_address_data.go
index 40d9b11..36d230c 100644
--- a/src/model/data/wallet_address_data.go
+++ b/src/model/data/wallet_address_data.go
@@ -1,14 +1,23 @@
package data
import (
+ "strings"
+
"github.com/assimon/luuu/model/dao"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/constant"
)
-// AddWalletAddress 创建钱包
+// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
- exist, err := GetWalletAddressByToken(address)
+ return AddWalletAddressWithNetwork(mdb.NetworkTron, address)
+}
+
+// AddWalletAddressWithNetwork 创建指定网络的钱包地址
+func AddWalletAddressWithNetwork(network, address string) (*mdb.WalletAddress, error) {
+ network = strings.ToLower(strings.TrimSpace(network))
+ address = strings.TrimSpace(address)
+ exist, err := GetWalletAddressByNetworkAndAddress(network, address)
if err != nil {
return nil, err
}
@@ -16,6 +25,7 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
return nil, constant.WalletAddressAlreadyExists
}
walletAddress := &mdb.WalletAddress{
+ Network: network,
Address: address,
Status: mdb.TokenStatusEnable,
}
@@ -23,7 +33,17 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
return walletAddress, err
}
-// GetWalletAddressByToken 通过钱包地址获取address
+// GetWalletAddressByNetworkAndAddress 通过网络和地址查询
+func GetWalletAddressByNetworkAndAddress(network, address string) (*mdb.WalletAddress, error) {
+ walletAddress := new(mdb.WalletAddress)
+ err := dao.Mdb.Model(walletAddress).
+ Where("network = ?", network).
+ Where("address = ?", address).
+ Limit(1).Find(walletAddress).Error
+ return walletAddress, err
+}
+
+// GetWalletAddressByToken 通过钱包地址获取address (兼容旧接口)
func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) {
walletAddress := new(mdb.WalletAddress)
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error
@@ -50,6 +70,16 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) {
return WalletAddressList, err
}
+// GetAvailableWalletAddressByNetwork 获得指定网络的所有可用钱包地址
+func GetAvailableWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
+ var list []mdb.WalletAddress
+ err := dao.Mdb.Model(list).
+ Where("status = ?", mdb.TokenStatusEnable).
+ Where("network = ?", network).
+ Find(&list).Error
+ return list, err
+}
+
// GetAllWalletAddress 获得所有钱包地址
func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
var WalletAddressList []mdb.WalletAddress
@@ -57,6 +87,13 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
return WalletAddressList, err
}
+// GetAllWalletAddressByNetwork 获得指定网络的所有钱包地址
+func GetAllWalletAddressByNetwork(network string) ([]mdb.WalletAddress, error) {
+ var list []mdb.WalletAddress
+ err := dao.Mdb.Model(list).Where("network = ?", network).Find(&list).Error
+ return list, err
+}
+
// ChangeWalletAddressStatus 启用禁用钱包
func ChangeWalletAddressStatus(id uint64, status int) error {
err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Update("status", status).Error
diff --git a/src/model/mdb/transaction_lock_mdb.go b/src/model/mdb/transaction_lock_mdb.go
index d94266e..0088199 100644
--- a/src/model/mdb/transaction_lock_mdb.go
+++ b/src/model/mdb/transaction_lock_mdb.go
@@ -4,9 +4,10 @@ import "time"
type TransactionLock struct {
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
- Address string `gorm:"column:address;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:1" json:"address"`
- Token string `gorm:"column:token;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:2" json:"token"`
- AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:3" json:"amount_scaled"`
+ Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
+ Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
+ Token string `gorm:"column:token;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:3" json:"token"`
+ AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:4" json:"amount_scaled"`
AmountText string `gorm:"column:amount_text" json:"amount_text"`
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go
index f58bbf7..1388f1a 100644
--- a/src/model/mdb/wallet_address_mdb.go
+++ b/src/model/mdb/wallet_address_mdb.go
@@ -5,6 +5,11 @@ const (
TokenStatusDisable = 2
)
+const (
+ NetworkTron = "tron"
+ NetworkSolana = "solana"
+)
+
type WalletAddress struct {
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network"`
Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address"`
diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go
index 3b81773..4a78c6c 100644
--- a/src/model/service/order_service.go
+++ b/src/model/service/order_service.go
@@ -28,8 +28,10 @@ const (
IncrementalMaximumNumber = 100
)
-var gCreateTransactionLock sync.Mutex
-var gOrderProcessingLock sync.Mutex
+var (
+ gCreateTransactionLock sync.Mutex
+ gOrderProcessingLock sync.Mutex
+)
// CreateTransaction creates a new payment order.
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
@@ -38,6 +40,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
token := strings.ToUpper(strings.TrimSpace(req.Token))
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
+ network := strings.ToLower(strings.TrimSpace(req.Network))
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
if rate <= 0 {
@@ -61,7 +64,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
return nil, constant.OrderAlreadyExists
}
- walletAddress, err := data.GetAvailableWalletAddress()
+ walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
if err != nil {
return nil, err
}
@@ -71,7 +74,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
tradeID := GenerateCode()
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
- availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, token, amount, walletAddress)
+ availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, network, token, amount, walletAddress)
if err != nil {
return nil, err
}
@@ -88,6 +91,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
ActualAmount: availableAmount,
ReceiveAddress: availableAddress,
Token: token,
+ Network: network,
Status: mdb.StatusWaitPay,
NotifyUrl: req.NotifyUrl,
RedirectUrl: req.RedirectUrl,
@@ -148,20 +152,20 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
return err
}
- if err = data.UnLockTransaction(req.ReceiveAddress, req.Token, req.Amount); err != nil {
+ if err = data.UnLockTransaction(req.Network, req.ReceiveAddress, req.Token, req.Amount); err != nil {
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
}
return nil
}
-// ReserveAvailableWalletAndAmount finds and locks an address+token+amount pair.
-func ReserveAvailableWalletAndAmount(tradeID string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
+// ReserveAvailableWalletAndAmount finds and locks a network+address+token+amount pair.
+func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
availableAddress := ""
availableAmount := amount
tryLockWalletFunc := func(targetAmount float64) (string, error) {
for _, address := range walletAddress {
- err := data.LockTransaction(address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
+ err := data.LockTransaction(network, address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration())
if err == nil {
return address.Address, nil
}
diff --git a/src/model/service/order_service_test.go b/src/model/service/order_service_test.go
index 3aad2e0..99d73b3 100644
--- a/src/model/service/order_service_test.go
+++ b/src/model/service/order_service_test.go
@@ -18,6 +18,7 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
OrderId: orderID,
Currency: "CNY",
Token: "USDT",
+ Network: "tron",
Amount: amount,
NotifyUrl: "https://merchant.example/callback",
}
@@ -53,7 +54,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
t.Fatalf("unexpected tokens: %s, %s", resp1.Token, resp2.Token)
}
- tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount)
+ tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount)
if err != nil {
t.Fatalf("get first runtime lock: %v", err)
}
@@ -61,7 +62,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId)
}
- tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount)
+ tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount)
if err != nil {
t.Fatalf("get second runtime lock: %v", err)
}
@@ -86,6 +87,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
+ Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_1",
@@ -108,7 +110,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId)
}
- tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp.ReceiveAddress, resp.Token, resp.ActualAmount)
+ tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", resp.ReceiveAddress, resp.Token, resp.ActualAmount)
if err != nil {
t.Fatalf("get runtime lock after processing: %v", err)
}
@@ -133,6 +135,7 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
req := &request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
+ Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_1",
@@ -180,6 +183,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
err = OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: resp.ReceiveAddress,
Token: resp.Token,
+ Network: "tron",
TradeId: resp.TradeId,
Amount: resp.ActualAmount,
BlockTransactionId: "block_expired",
@@ -239,6 +243,7 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
errs <- OrderProcessing(&request.OrderProcessingRequest{
ReceiveAddress: address,
Token: token,
+ Network: "tron",
TradeId: tradeID,
Amount: amount,
BlockTransactionId: "shared_block",
diff --git a/src/model/service/sol_task.go b/src/model/service/sol_task.go
index 24c2e8f..06731f9 100644
--- a/src/model/service/sol_task.go
+++ b/src/model/service/sol_task.go
@@ -1,22 +1,419 @@
package service
import (
- "context"
- "encoding/base64"
- "encoding/binary"
+ "encoding/json"
"errors"
"fmt"
+ "math/big"
+ "sort"
+ "strings"
+ "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/gagliardetto/solana-go"
- "github.com/gagliardetto/solana-go/rpc"
+ "github.com/go-resty/resty/v2"
+ "github.com/shopspring/decimal"
+ "github.com/tidwall/gjson"
)
-func SolCallBack(address string) {}
+// gProcessedSignatures 已处理签名缓存,避免重复调用 getTransaction
+var gProcessedSignatures sync.Map // sig -> unix timestamp
+
+type TransferInfo struct {
+ Source string // Source address (for SOL) or source ATA (for SPL tokens)
+ Destination string // Destination address (for SOL) or destination ATA (for SPL tokens)
+ Mint string // Token mint (e.g. USDT mint, USDC mint, or "SOL" for native transfers)
+ Amount float64 // Human-readable amount (adjusted for decimals)
+ RawAmount uint64 // Raw amount from the transaction (before adjusting for decimals)
+ Decimals *int // Optional decimals from the transaction, if available
+ BlockTime int64 // Block time of the transfer unit is seconds since epoch
+}
+
+// SolCallBack 扫描指定钱包地址的 Solana 链上交易,匹配待支付订单并确认收款。
+func SolCallBack(address string, wg *sync.WaitGroup) {
+ defer wg.Done()
+ defer func() {
+ if err := recover(); err != nil {
+ log.Sugar.Errorf("[SOL][%s] panic recovered: %v", address, err)
+ }
+ }()
+
+ // 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 {
+ if ts, ok := value.(int64); ok && ts < cleanupCutoff {
+ gProcessedSignatures.Delete(key)
+ }
+ return true
+ })
+
+ limit := 1000
+
+ // 查询钱包地址 + USDT ATA + USDC 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)
+ }
+
+ // 拉取签名并去重
+ seen := make(map[string]bool)
+ var result []solSignatureResult
+ for _, queryAddr := range queryAddrs {
+ respBody, err := SolGetSignaturesForAddress(queryAddr, limit, "", "")
+ if err != nil {
+ log.Sugar.Errorf("[SOL][%s] SolGetSignaturesForAddress(%s) failed: %v", address, queryAddr, err)
+ continue
+ }
+
+ resultBody := gjson.GetBytes(respBody, "result")
+ if !resultBody.Exists() || !resultBody.IsArray() {
+ log.Sugar.Errorf("[SOL][%s] unexpected response format for %s: %s", address, queryAddr, string(respBody))
+ continue
+ }
+
+ var batch []solSignatureResult
+ err = json.Unmarshal([]byte(resultBody.Raw), &batch)
+ if err != nil {
+ log.Sugar.Errorf("[SOL][%s] failed to unmarshal signatures for %s: %v", address, queryAddr, err)
+ continue
+ }
+
+ for _, sig := range batch {
+ if !seen[sig.Signature] {
+ seen[sig.Signature] = true
+ result = append(result, sig)
+ }
+ }
+ }
+
+ if len(result) == 0 {
+ log.Sugar.Debugf("[SOL][%s] no transaction signatures found", address)
+ return
+ }
+
+ // 按 blockTime 降序排列
+ sort.Slice(result, func(i, j int) bool {
+ if result[i].BlockTime == nil {
+ return false
+ }
+ if result[j].BlockTime == nil {
+ return true
+ }
+ return *result[i].BlockTime > *result[j].BlockTime
+ })
+
+ // 时间截止线:订单过期时间 + 5 分钟
+ cutoffTime := time.Now().Add(-config.GetOrderExpirationTimeDuration() - 5*time.Minute).Unix()
+
+ log.Sugar.Debugf("[SOL][%s] fetched %d unique signatures from %d addresses, cutoff=%d",
+ address, len(result), len(queryAddrs), cutoffTime)
+
+ // Process each transaction signature
+ for sigIdx, txSig := range result {
+ sig := txSig.Signature
+
+ // Skip failed transactions
+ if txSig.Err != nil {
+ continue
+ }
+
+ // 超过截止时间的旧签名不再处理
+ if txSig.BlockTime != nil && *txSig.BlockTime < cutoffTime {
+ log.Sugar.Debugf("[SOL][%s] [%d/%d] sig=%s blockTime=%d before cutoff=%d, stopping scan",
+ address, sigIdx+1, len(result), sig, *txSig.BlockTime, cutoffTime)
+ break
+ }
+
+ // 跳过已处理的签名
+ if _, ok := gProcessedSignatures.Load(sig); ok {
+ continue
+ }
+
+ log.Sugar.Debugf("[SOL][%s] [%d/%d] processing sig=%s slot=%d", address, sigIdx+1, len(result), sig, txSig.Slot)
+
+ txData, err := SolGetTransaction(sig)
+ if err != nil {
+ log.Sugar.Debugf("[SOL][%s] sig=%s fetch failed: %v", address, sig, err)
+ continue
+ }
+
+ instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
+ log.Sugar.Debugf("[SOL][%s] sig=%s has %d instructions", address, sig, len(instructions))
+
+ for instrIdx, instruction := range instructions {
+ programID := instruction.Get("programId").String()
+ parsedType := instruction.Get("parsed.type").String()
+ log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] programId=%s parsedType=%s", address, sig, instrIdx, programID, parsedType)
+
+ transferInfo, err := ParseTransferInfoFromInstruction(instruction, txData)
+ if err != nil {
+ log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] parse error: %v", address, sig, instrIdx, err)
+ continue
+ }
+ if transferInfo == nil {
+ continue
+ }
+
+ log.Sugar.Debugf("[SOL][%s] sig=%s instr[%d] transfer: src=%s dst=%s mint=%s rawAmount=%d amount=%.6f blockTime=%d",
+ address, sig, instrIdx,
+ transferInfo.Source, transferInfo.Destination, transferInfo.Mint,
+ transferInfo.RawAmount, transferInfo.Amount, transferInfo.BlockTime)
+
+ if !isTransferToAddress(transferInfo, address) {
+ continue
+ }
+
+ token, amount := getTokenTypeAndAmount(transferInfo)
+ if token == "" || amount <= 0 {
+ continue
+ }
+
+ log.Sugar.Infof("[SOL][%s] sig=%s instr[%d] incoming transfer confirmed: token=%s amount=%.2f -> querying transaction_lock network=solana address=%s token=%s amount=%.2f",
+ address, sig, instrIdx, token, amount, address, token, amount)
+
+ tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkSolana, address, token, amount)
+ if err != nil {
+ log.Sugar.Errorf("[SOL][%s] sig=%s query transaction_lock failed: %v", address, sig, err)
+ continue
+ }
+ if tradeID == "" {
+ log.Sugar.Infof("[SOL][%s] sig=%s no active transaction_lock matched: network=solana address=%s token=%s amount=%.2f (no order or expired)",
+ address, sig, address, token, amount)
+ continue
+ }
+ log.Sugar.Infof("[SOL][%s] transaction_lock matched: trade_id=%s sig=%s token=%s amount=%.2f",
+ address, tradeID, sig, token, amount)
+
+ order, err := data.GetOrderInfoByTradeId(tradeID)
+ if err != nil {
+ log.Sugar.Errorf("[SOL][%s] sig=%s load order failed for trade_id=%s: %v", address, sig, tradeID, err)
+ continue
+ }
+ log.Sugar.Infof("[SOL][%s] order loaded: trade_id=%s order_id=%s status=%d created_at_ms=%d",
+ address, tradeID, order.OrderId, order.Status, order.CreatedAt.TimestampMilli())
+
+ // blockTime 秒 → 毫秒,与订单创建时间对齐
+ blockTimestamp := transferInfo.BlockTime * 1000
+ createTime := order.CreatedAt.TimestampMilli()
+ log.Sugar.Infof("[SOL][%s] time check: sig=%s block_time_ms=%d order_created_ms=%d diff_ms=%d",
+ address, sig, blockTimestamp, createTime, blockTimestamp-createTime)
+ if blockTimestamp < createTime {
+ log.Sugar.Warnf("[SOL][%s] sig=%s skipped: block_time_ms=%d is %d ms before order created_ms=%d (transaction predates the order)",
+ address, sig, blockTimestamp, createTime-blockTimestamp, createTime)
+ continue
+ }
+
+ req := &request.OrderProcessingRequest{
+ ReceiveAddress: address,
+ Token: token,
+ Network: mdb.NetworkSolana,
+ TradeId: tradeID,
+ Amount: amount,
+ BlockTransactionId: sig,
+ }
+ log.Sugar.Infof("[SOL][%s] calling OrderProcessing: trade_id=%s sig=%s token=%s amount=%.2f",
+ address, tradeID, sig, token, amount)
+ err = OrderProcessing(req)
+ if err != nil {
+ if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
+ log.Sugar.Infof("[SOL][%s] sig=%s already resolved: trade_id=%s reason=%v", address, sig, tradeID, err)
+ continue
+ }
+ log.Sugar.Errorf("[SOL][%s] sig=%s OrderProcessing failed for trade_id=%s: %v", address, sig, tradeID, err)
+ continue
+ }
+
+ log.Sugar.Infof("[SOL][%s] order marked paid: trade_id=%s sig=%s token=%s amount=%.2f, sending telegram notification",
+ address, tradeID, sig, token, amount)
+ sendPaymentNotification(order)
+ log.Sugar.Infof("[SOL][%s] payment fully processed: trade_id=%s sig=%s", address, tradeID, sig)
+ }
+
+ // 标记已处理
+ gProcessedSignatures.Store(sig, time.Now().Unix())
+ }
+}
+
+// solSignatureResult getSignaturesForAddress 返回结构
+type solSignatureResult struct {
+ Signature string `json:"signature"`
+ Slot uint64 `json:"slot"`
+ Err interface{} `json:"err"`
+ BlockTime *int64 `json:"blockTime"`
+}
+
+// SolRetryClient 发送 Solana JSON-RPC 请求,自动重试
+func SolRetryClient(method string, params []interface{}) ([]byte, error) {
+ client := resty.New()
+ client.SetRetryCount(5)
+ client.SetRetryWaitTime(2 * time.Second)
+ client.SetRetryMaxWaitTime(10 * time.Second)
+ client.AddRetryCondition(func(r *resty.Response, err error) bool {
+ if err != nil {
+ return true
+ }
+ if r.StatusCode() >= 429 || r.StatusCode() >= 500 {
+ return true
+ }
+ return false
+ })
+
+ rpcUrl := config.GetSolanaRpcUrl()
+
+ resp, err := client.R().
+ SetHeader("Content-Type", "application/json").
+ SetBody(map[string]interface{}{
+ "jsonrpc": "2.0",
+ "id": 1,
+ "method": method,
+ "params": params,
+ }).
+ Post(rpcUrl)
+ if err != nil {
+ return nil, err
+ }
+
+ respBody := resp.Body()
+
+ return respBody, nil
+}
+
+func SolGetSignaturesForAddress(address string, limit int, untilSig string, beforeSig string) ([]byte, error) {
+ opts := map[string]interface{}{
+ "commitment": "finalized",
+ "limit": limit,
+ }
+ if untilSig != "" {
+ opts["until"] = untilSig
+ }
+ if beforeSig != "" {
+ opts["before"] = beforeSig
+ }
+
+ bodyData, err := SolRetryClient("getSignaturesForAddress",
+ []interface{}{address, opts})
+ if err != nil {
+ return nil, err
+ }
+
+ var result map[string]interface{}
+ err = json.Unmarshal(bodyData, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ _, ok := result["result"].([]interface{})
+ if !ok {
+ return nil, fmt.Errorf("unexpected response format: %v", result)
+ }
+
+ return bodyData, nil
+}
+
+func SolGetTransaction(sig string) ([]byte, error) {
+ txData, err := SolRetryClient("getTransaction", []interface{}{
+ sig,
+ map[string]interface{}{
+ "encoding": "jsonParsed",
+ "commitment": "confirmed",
+ "maxSupportedTransactionVersion": 0, // suport
+ },
+ })
+ if err != nil {
+ log.Sugar.Errorf("SolRetryClient failed: %v", err)
+ return nil, err
+ }
+
+ errField := gjson.GetBytes(txData, "result.meta.err")
+ if errField.Exists() && errField.Type != gjson.Null {
+ log.Sugar.Warnf("Transaction failed: %v", errField.String())
+ return nil, fmt.Errorf("transaction failed: %s", errField.String())
+ }
+
+ return txData, nil
+}
+
+// isTransferToAddress 判断转账目标是否为指定钱包地址
+func isTransferToAddress(transfer *TransferInfo, targetAddress string) bool {
+ // Native SOL transfer - check destination directly
+ if transfer.Mint == "SOL" {
+ return strings.EqualFold(transfer.Destination, targetAddress)
+ }
+
+ // Skip Transfer instruction without mint info (use TransferChecked instead)
+ if transfer.Mint == "" {
+ return false
+ }
+
+ // SPL Token transfer - check if destination ATA matches
+ return MatchAtaAddress(targetAddress, transfer.Mint, transfer.Destination)
+}
+
+// getTokenTypeAndAmount 根据 mint 识别代币类型并计算可读金额
+func getTokenTypeAndAmount(transfer *TransferInfo) (string, float64) {
+ mint := transfer.Mint
+
+ // Native SOL
+ if mint == "SOL" {
+ return "SOL", transfer.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
+ return "", 0
+ }
+}
const (
// Mint token
USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
USDC_Mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
+
+ USDT_Decimals = 6
+ USDC_Decimals = 6
+ SOL_Decimals = 9
+)
+
+const (
+ // SPL Token instructions
+ InstructionTransfer = 3
+ InstructionTransferChecked = 12
+)
+
+const (
+ // System Program
+ SystemProgramID = "11111111111111111111111111111111"
+ InstructionSystemTransfer = 2
)
const (
@@ -24,6 +421,49 @@ const (
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
)
+// ADJustAmount 将链上原始金额转为可读金额(除以 10^decimals,保留 2 位小数)
+func ADJustAmount(amount uint64, decimals int) float64 {
+ if amount == 0 {
+ return 0
+ }
+ decimalAmount := decimal.NewFromBigInt(new(big.Int).SetUint64(amount), 0)
+ // 10^decimals
+ decimalDivisor := decimal.New(1, int32(decimals))
+ adjustedAmount := decimalAmount.Div(decimalDivisor)
+ // Round to 2 decimal places
+ return math.MustParsePrecFloat64(adjustedAmount.InexactFloat64(), 2)
+}
+
+func MatchUsdtAtaAddress(address string, ataTo string) bool {
+ ata, err := FindATAAddress(address, USDT_Mint)
+ if err != nil {
+ fmt.Printf("FindATAAddress failed: %v\n", err)
+ return false
+ }
+
+ return strings.EqualFold(ata, ataTo)
+}
+
+func MatchUsdcAtaAddress(address string, ataTo string) bool {
+ ata, err := FindATAAddress(address, USDC_Mint)
+ if err != nil {
+ fmt.Printf("FindATAAddress failed: %v\n", err)
+ return false
+ }
+
+ return strings.EqualFold(ata, ataTo)
+}
+
+func MatchAtaAddress(address string, mint string, ataTo string) bool {
+ ata, err := FindATAAddress(address, mint)
+ if err != nil {
+ fmt.Printf("FindATAAddress failed: %v\n", err)
+ return false
+ }
+
+ return strings.EqualFold(ata, ataTo)
+}
+
func FindATAAddress(owner, mint string) (string, error) {
ownerPubKey, err := solana.PublicKeyFromBase58(owner)
if err != nil {
@@ -43,191 +483,127 @@ func FindATAAddress(owner, mint string) (string, error) {
return ata.String(), nil
}
-const (
- InstructionTransfer = 3
- InstructionTransferChecked = 12
-)
+// ParseTransferInfoFromInstruction 从单条指令中解析转账信息,非转账指令返回 nil
+func ParseTransferInfoFromInstruction(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
+ programID := instruction.Get("programId").String()
+ parsedType := instruction.Get("parsed.type").String()
-type TransferInfo struct {
- ProgramID string
- Type string
+ if programID == SystemProgramID && parsedType == "transfer" {
+ return parseSystemTransfer(instruction, txData)
+ }
- Source string
- Destination string
- Mint string
- Authority string
+ if programID == TokenProgramID || programID == Token2022ProgramID {
+ switch parsedType {
+ case "transfer":
+ return parseSplTransfer(instruction, txData)
+ case "transferChecked":
+ return parseSplTransferChecked(instruction, txData)
+ }
+ }
- Amount uint64
- Decimals *uint8
+ // Skip non-transfer instructions (ComputeBudget, AToken create, etc.)
+ return nil, nil
}
-func isTokenProgram(programID solana.PublicKey) bool {
- p := programID.String()
- return p == TokenProgramID || p == Token2022ProgramID
+func parseSystemTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
+ info := instruction.Get("parsed.info")
+ source := info.Get("source").String()
+ destination := info.Get("destination").String()
+ lamports := info.Get("lamports").Uint()
+ blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
+
+ return &TransferInfo{
+ Source: source,
+ Destination: destination,
+ Mint: "SOL",
+ Amount: ADJustAmount(lamports, SOL_Decimals),
+ RawAmount: lamports,
+ BlockTime: blockTime,
+ }, nil
}
-func DecodeTransfer(data []byte, accountKeys []solana.PublicKey, accountIndexes []uint16, programID solana.PublicKey) (*TransferInfo, error) {
- if len(data) < 9 {
- return nil, fmt.Errorf("invalid transfer data length: %d", len(data))
+// parseSplTransfer 解析 SPL Token "transfer" 指令,mint 从 postTokenBalances 中查找
+func parseSplTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
+ info := instruction.Get("parsed.info")
+ source := info.Get("source").String()
+ destination := info.Get("destination").String()
+ amountStr := info.Get("amount").String()
+ blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
+
+ rawAmount, ok := new(big.Int).SetString(amountStr, 10)
+ if !ok {
+ return nil, fmt.Errorf("invalid amount: %s", amountStr)
}
- if data[0] != InstructionTransfer {
- return nil, fmt.Errorf("not transfer instruction, got: %d", data[0])
+ // Look up mint and decimals from postTokenBalances using the destination ATA
+ mint, decimals, found := findMintFromTokenBalances(destination, txData)
+ if !found {
+ // Try source ATA as fallback
+ mint, decimals, found = findMintFromTokenBalances(source, txData)
+ }
+ if !found {
+ return nil, fmt.Errorf("could not determine mint for transfer: source=%s dest=%s", source, destination)
}
- if len(accountIndexes) < 3 {
- return nil, fmt.Errorf("transfer accounts not enough: %d", len(accountIndexes))
- }
-
- amount := binary.LittleEndian.Uint64(data[1:9])
-
- sourceIdx := accountIndexes[0]
- destIdx := accountIndexes[1]
- authIdx := accountIndexes[2]
-
- if int(sourceIdx) >= len(accountKeys) || int(destIdx) >= len(accountKeys) || int(authIdx) >= len(accountKeys) {
- return nil, errors.New("account index out of range")
- }
-
- info := &TransferInfo{
- ProgramID: programID.String(),
- Type: "transfer",
- Source: accountKeys[sourceIdx].String(),
- Destination: accountKeys[destIdx].String(),
- Authority: accountKeys[authIdx].String(),
- Amount: amount,
- }
-
- return info, nil
+ d := decimals
+ return &TransferInfo{
+ Source: source,
+ Destination: destination,
+ Mint: mint,
+ Amount: ADJustAmount(rawAmount.Uint64(), decimals),
+ RawAmount: rawAmount.Uint64(),
+ Decimals: &d,
+ BlockTime: blockTime,
+ }, nil
}
-type TransferCheckedInfo struct {
- ProgramID string
- Type string
+// parseSplTransferChecked 解析 SPL Token "transferChecked" 指令
+func parseSplTransferChecked(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
+ info := instruction.Get("parsed.info")
+ source := info.Get("source").String()
+ destination := info.Get("destination").String()
+ mint := info.Get("mint").String()
+ amountStr := info.Get("tokenAmount.amount").String()
+ decimals := int(info.Get("tokenAmount.decimals").Int())
+ blockTime := gjson.GetBytes(txData, "result.blockTime").Int()
- Source string
- Destination string
- Mint string
- Authority string
-
- Amount uint64
- Decimals uint8
-}
-
-func DecodeTransferCheck(data []byte, accountKeys []solana.PublicKey, accountIndexes []uint16, programID solana.PublicKey) (*TransferInfo, error) {
- if len(data) < 10 {
- return nil, fmt.Errorf("invalid transferChecked data length: %d", len(data))
+ rawAmount, ok := new(big.Int).SetString(amountStr, 10)
+ if !ok {
+ return nil, fmt.Errorf("invalid amount: %s", amountStr)
}
- if data[0] != InstructionTransferChecked {
- return nil, fmt.Errorf("not transferChecked instruction, got: %d", data[0])
- }
-
- if len(accountIndexes) < 4 {
- return nil, fmt.Errorf("transferChecked accounts not enough: %d", len(accountIndexes))
- }
-
- amount := binary.LittleEndian.Uint64(data[1:9])
- decimals := uint8(data[9])
-
- sourceIdx := accountIndexes[0]
- mintIdx := accountIndexes[1]
- destIdx := accountIndexes[2]
- authIdx := accountIndexes[3]
-
- if int(sourceIdx) >= len(accountKeys) ||
- int(mintIdx) >= len(accountKeys) ||
- int(destIdx) >= len(accountKeys) ||
- int(authIdx) >= len(accountKeys) {
- return nil, errors.New("account index out of range")
- }
-
- info := &TransferInfo{
- ProgramID: programID.String(),
- Type: "transferChecked",
- Source: accountKeys[sourceIdx].String(),
- Mint: accountKeys[mintIdx].String(),
- Destination: accountKeys[destIdx].String(),
- Authority: accountKeys[authIdx].String(),
- Amount: amount,
+ return &TransferInfo{
+ Source: source,
+ Destination: destination,
+ Mint: mint,
+ Amount: ADJustAmount(rawAmount.Uint64(), decimals),
+ RawAmount: rawAmount.Uint64(),
Decimals: &decimals,
- }
-
- return info, nil
+ BlockTime: blockTime,
+ }, nil
}
-// ParseTransactionTransfers
-func ParseTransactionTransfers(ctx context.Context, client *rpc.Client, sig string) ([]*TransferInfo, error) {
- signature, err := solana.SignatureFromBase58(sig)
- if err != nil {
- return nil, fmt.Errorf("invalid signature: %w", err)
- }
-
- tx, err := client.GetTransaction(
- ctx,
- signature,
- &rpc.GetTransactionOpts{
- Encoding: solana.EncodingBase64,
- Commitment: rpc.CommitmentConfirmed,
- MaxSupportedTransactionVersion: func(v uint64) *uint64 { return &v }(0),
- },
- )
- if err != nil {
- return nil, fmt.Errorf("get transaction failed: %w", err)
- }
- if tx == nil {
- return nil, errors.New("transaction not found")
- }
-
- decodedTx, err := tx.Transaction.GetTransaction()
- if err != nil {
- return nil, fmt.Errorf("decode transaction failed: %w", err)
- }
-
- msg := decodedTx.Message
- accountKeys := msg.AccountKeys
- results := make([]*TransferInfo, 0)
-
- for _, ix := range msg.Instructions {
- if int(ix.ProgramIDIndex) >= len(accountKeys) {
- continue
- }
-
- programID := accountKeys[ix.ProgramIDIndex]
- if !isTokenProgram(programID) {
- continue
- }
-
- data := ix.Data
- if len(data) == 0 {
- continue
- }
-
- switch data[0] {
- case InstructionTransfer:
- info, err := DecodeTransfer(data, accountKeys, toUint16Slice(ix.Accounts), programID)
- if err == nil {
- results = append(results, info)
- }
- case InstructionTransferChecked:
- info, err := DecodeTransferCheck(data, accountKeys, toUint16Slice(ix.Accounts), programID)
- if err == nil {
- results = append(results, info)
- }
+// findMintFromTokenBalances 从 postTokenBalances 中查找 ATA 对应的 mint 和 decimals
+func findMintFromTokenBalances(ataAddress string, txData []byte) (string, int, bool) {
+ accountKeys := gjson.GetBytes(txData, "result.transaction.message.accountKeys").Array()
+ accountIndex := -1
+ for i, key := range accountKeys {
+ if key.Get("pubkey").String() == ataAddress {
+ accountIndex = i
+ break
}
}
+ if accountIndex == -1 {
+ return "", 0, false
+ }
- return results, nil
-}
-
-func toUint16Slice(in []uint16) []uint16 {
- out := make([]uint16, len(in))
- for i, v := range in {
- out[i] = uint16(v)
- }
- return out
-}
-
-func DecodeBase64InstructionData(s string) ([]byte, error) {
- return base64.StdEncoding.DecodeString(s)
+ balances := gjson.GetBytes(txData, "result.meta.postTokenBalances").Array()
+ for _, balance := range balances {
+ if int(balance.Get("accountIndex").Int()) == accountIndex {
+ mint := balance.Get("mint").String()
+ decimals := int(balance.Get("uiTokenAmount.decimals").Int())
+ return mint, decimals, true
+ }
+ }
+ return "", 0, false
}
diff --git a/src/model/service/sol_task_test.go b/src/model/service/sol_task_test.go
index f694a4e..924a2a6 100644
--- a/src/model/service/sol_task_test.go
+++ b/src/model/service/sol_task_test.go
@@ -1,45 +1,349 @@
package service
import (
- "context"
+ "encoding/json"
"fmt"
"testing"
- "github.com/gagliardetto/solana-go/rpc"
+ "github.com/tidwall/gjson"
)
-func TestParseSolTransaction(t *testing.T) {
- client := rpc.New("https://api.mainnet-beta.solana.com")
- sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
-
- txInfo, err := ParseTransactionTransfers(context.Background(), client, sig)
+func TestSolClientHealthy(t *testing.T) {
+ bodyData, err := SolRetryClient("getHealth", nil)
if err != nil {
- panic(err)
+ t.Fatalf("SolRetryClient failed: %v", err)
}
- for _, item := range txInfo {
- fmt.Println("type =", item.Type)
- fmt.Println("program =", item.ProgramID)
- fmt.Println("from =", item.Source)
- fmt.Println("to =", item.Destination)
- fmt.Println("mint =", item.Mint)
- fmt.Println("authority =", item.Authority)
- fmt.Println("amount =", item.Amount)
- if item.Decimals != nil {
- fmt.Println("decimals =", *item.Decimals)
- }
- fmt.Println("-----")
+ var result map[string]interface{}
+ err = json.Unmarshal(bodyData, &result)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal response: %v", err)
}
+
+ status, ok := result["result"].(string)
+ if !ok {
+ t.Fatalf("Unexpected response format: %v", result)
+ }
+
+ t.Logf("RPC Health Status: %s", status)
+
+ if status != "ok" {
+ t.Errorf("Expected health status 'ok', got '%s'", status)
+ }
+}
+
+func TestSolClientGetSignaturesForAddress(t *testing.T) {
+ // Example wallet address (replace with actual test address)
+ address := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
+
+ bodyData, err := SolRetryClient("getSignaturesForAddress", []interface{}{address, map[string]interface{}{"commitment": "finalized", "limit": 100}})
+ if err != nil {
+ t.Fatalf("SolRetryClient failed: %v", err)
+ }
+
+ var result map[string]interface{}
+ err = json.Unmarshal(bodyData, &result)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal response: %v", err)
+ }
+
+ signatures, ok := result["result"].([]interface{})
+ if !ok {
+ t.Fatalf("Unexpected response format: %v", result)
+ }
+
+ t.Logf("Found %d signatures for address %s", len(signatures), address)
+
+}
+
+func TestSolClientGetTransaction(t *testing.T) {
+ // Example transaction signature (replace with actual test signature)
+ sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
+
+ txData, err := SolRetryClient("getTransaction", []interface{}{sig, map[string]interface{}{"encoding": "jsonParsed", "commitment": "finalized"}})
+ 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)
+ if err != nil {
+ t.Fatalf("Failed to unmarshal response: %v", err)
+ }
+
+ txInfo, ok := result["result"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("Unexpected response format: %v", result)
+ }
+
+ t.Logf("Transaction Info for signature %s: %v", sig, txInfo)
}
func TestFindATAAddress(t *testing.T) {
- owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
- mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
-
- ata, err := FindATAAddress(owner, mint)
- if err != nil {
- panic(err)
+ tests := []struct {
+ name string
+ owner string
+ mint string
+ want string
+ }{
+ {
+ name: "RAY token ATA",
+ owner: "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu",
+ mint: "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R",
+ want: "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4",
+ },
}
- fmt.Println("ATA =", ata)
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ata, err := FindATAAddress(tt.owner, tt.mint)
+ if err != nil {
+ t.Fatalf("FindATAAddress failed: %v", err)
+ }
+
+ t.Logf("Owner: %s", tt.owner)
+ t.Logf("Mint: %s", tt.mint)
+ t.Logf("ATA: %s", ata)
+
+ if tt.want != "" && ata != tt.want {
+ t.Errorf("Expected ATA %s, got %s", tt.want, ata)
+ }
+ })
+ }
+}
+
+func TestMatchATAAddress(t *testing.T) {
+ owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
+ mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
+ expectedATA := "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4"
+
+ ok := MatchAtaAddress(owner, mint, expectedATA)
+ t.Logf("Owner: %s", owner)
+ t.Logf("Mint: %s", mint)
+ t.Logf("Expected ATA: %s", expectedATA)
+ t.Logf("Match result: %v", ok)
+
+ if !ok {
+ t.Error("Expected ATA to match, but it didn't")
+ }
+}
+
+func TestMatchUsdtAtaAddress(t *testing.T) {
+ // Example wallet address (replace with actual test address)
+ owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
+
+ ata, err := FindATAAddress(owner, USDT_Mint)
+ if err != nil {
+ t.Fatalf("FindATAAddress failed: %v", err)
+ }
+
+ t.Logf("Owner: %s", owner)
+ t.Logf("USDT Mint: %s", USDT_Mint)
+ t.Logf("USDT ATA: %s", ata)
+
+ ok := MatchUsdtAtaAddress(owner, ata)
+ if !ok {
+ t.Error("Expected USDT ATA to match")
+ }
+}
+
+func TestMatchUsdcAtaAddress(t *testing.T) {
+ // Example wallet address (replace with actual test address)
+ owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
+
+ ata, err := FindATAAddress(owner, USDC_Mint)
+ if err != nil {
+ t.Fatalf("FindATAAddress failed: %v", err)
+ }
+
+ t.Logf("Owner: %s", owner)
+ t.Logf("USDC Mint: %s", USDC_Mint)
+ t.Logf("USDC ATA: %s", ata)
+
+ ok := MatchUsdcAtaAddress(owner, ata)
+ if !ok {
+ t.Error("Expected USDC ATA to match")
+ }
+}
+
+func TestAdjustAmount(t *testing.T) {
+ tests := []struct {
+ name string
+ amount uint64
+ decimals int
+ want float64
+ }{
+ {
+ name: "USDT amount (6 decimals)",
+ amount: 123456789,
+ decimals: 6,
+ want: 123.46,
+ },
+ {
+ name: "USDC amount (6 decimals)",
+ amount: 1000000,
+ decimals: 6,
+ want: 1.0,
+ },
+ {
+ name: "SOL amount (9 decimals)",
+ amount: 1000000000,
+ decimals: 9,
+ want: 1.0,
+ },
+ {
+ name: "Zero amount",
+ amount: 0,
+ decimals: 6,
+ want: 0,
+ },
+ {
+ name: "Small amount",
+ amount: 1,
+ decimals: 6,
+ want: 0.0,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ adjusted := ADJustAmount(tt.amount, tt.decimals)
+ t.Logf("Raw amount: %d, Decimals: %d, Adjusted: %.2f", tt.amount, tt.decimals, adjusted)
+
+ if adjusted != tt.want {
+ t.Errorf("Expected %.2f, got %.2f", tt.want, adjusted)
+ }
+ })
+ }
+}
+
+func TestParseTransferInfoFromInstruction_SplTransfer(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test")
+ }
+
+ // SPL Token "transfer" (no mint in instruction, must look up from postTokenBalances)
+ sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
+ txData, err := SolGetTransaction(sig)
+ if err != nil {
+ t.Fatalf("SolGetTransaction failed: %v", err)
+ }
+
+ instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
+ var found bool
+ for _, inst := range instructions {
+ info, err := ParseTransferInfoFromInstruction(inst, txData)
+ if err != nil {
+ t.Logf("parse error (ok to skip): %v", err)
+ continue
+ }
+ if info == nil {
+ continue
+ }
+ found = true
+ t.Logf("SPL transfer: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
+ info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
+
+ if info.Mint == "" {
+ t.Error("Expected mint to be resolved from postTokenBalances")
+ }
+ if info.RawAmount != 50000 {
+ t.Errorf("Expected raw amount 50000, got %d", info.RawAmount)
+ }
+ if info.BlockTime == 0 {
+ t.Error("Expected non-zero blockTime")
+ }
+ }
+ if !found {
+ t.Error("No transfer instruction found in transaction")
+ }
+}
+
+func TestParseTransferInfoFromInstruction_TransferChecked(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test")
+ }
+
+ // SPL Token "transferChecked" (has mint and tokenAmount in instruction)
+ sig := "2aEoNykk4ZJ27C3y7EDJiQUc7GFnnsMe7ofFzB73swGL8kTxSBFCnwzWw3jzr3BND7k8hx15fZHUUAbG1XemNFe5"
+ txData, err := SolGetTransaction(sig)
+ if err != nil {
+ t.Fatalf("SolGetTransaction failed: %v", err)
+ }
+
+ instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
+ var found bool
+ for _, inst := range instructions {
+ info, err := ParseTransferInfoFromInstruction(inst, txData)
+ if err != nil {
+ t.Logf("parse error (ok to skip): %v", err)
+ continue
+ }
+ if info == nil {
+ continue
+ }
+ found = true
+ t.Logf("TransferChecked: source=%s dest=%s mint=%s amount=%.6f raw=%d blockTime=%d",
+ info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
+
+ if info.Mint != USDT_Mint {
+ t.Errorf("Expected USDT mint %s, got %s", USDT_Mint, info.Mint)
+ }
+ if info.RawAmount != 300000 {
+ t.Errorf("Expected raw amount 300000, got %d", info.RawAmount)
+ }
+ if info.Amount != 0.3 {
+ t.Errorf("Expected amount 0.3, got %f", info.Amount)
+ }
+ if info.BlockTime == 0 {
+ t.Error("Expected non-zero blockTime")
+ }
+ }
+ if !found {
+ t.Error("No transferChecked instruction found in transaction")
+ }
+}
+
+func TestParseTransferInfoFromInstruction_SystemTransfer(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test")
+ }
+
+ // System program SOL transfer
+ sig := "5pNMonUBvLVpxXTmyd5CGVBs49W6781g2ACnrCXhbmtz58KENYA7HSqu6hQkQweg3qQboRd8WAscphNAtiq9UtZZ"
+ txData, err := SolGetTransaction(sig)
+ if err != nil {
+ t.Fatalf("SolGetTransaction failed: %v", err)
+ }
+
+ instructions := gjson.GetBytes(txData, "result.transaction.message.instructions").Array()
+ transferCount := 0
+ for _, inst := range instructions {
+ info, err := ParseTransferInfoFromInstruction(inst, txData)
+ if err != nil {
+ t.Logf("parse error (ok to skip): %v", err)
+ continue
+ }
+ if info == nil {
+ continue
+ }
+ transferCount++
+ t.Logf("System transfer #%d: source=%s dest=%s mint=%s amount=%.9f raw=%d blockTime=%d",
+ transferCount, info.Source, info.Destination, info.Mint, info.Amount, info.RawAmount, info.BlockTime)
+
+ if info.Mint != "SOL" {
+ t.Errorf("Expected mint SOL, got %s", info.Mint)
+ }
+ if info.RawAmount == 0 {
+ t.Error("Expected non-zero raw amount")
+ }
+ if info.BlockTime == 0 {
+ t.Error("Expected non-zero blockTime")
+ }
+ }
+ if transferCount == 0 {
+ t.Error("No system transfer instruction found")
+ }
+ t.Logf("Found %d system transfers", transferCount)
}
diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go
index a0bd8bc..fefcedf 100644
--- a/src/model/service/task_service.go
+++ b/src/model/service/task_service.go
@@ -110,7 +110,7 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
}
txID := transfer.Get("txID").String()
- tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount)
+ tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "TRX", amount)
if err != nil {
panic(err)
}
@@ -134,6 +134,7 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "TRX",
+ Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
@@ -212,7 +213,7 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
}
txID := transfer.Get("transaction_id").String()
- tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount)
+ tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "USDT", amount)
if err != nil {
panic(err)
}
@@ -236,6 +237,7 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
req := &request.OrderProcessingRequest{
ReceiveAddress: address,
Token: "USDT",
+ Network: mdb.NetworkTron,
TradeId: tradeID,
Amount: amount,
BlockTransactionId: txID,
@@ -263,6 +265,7 @@ func sendPaymentNotification(order *mdb.Orders) {
"📋 订单信息\n"+
"├ 交易号:%s\n"+
"├ 订单号:%s\n"+
+ "├ 网络:%s\n"+
"└ 钱包地址:%s\n\n"+
"⏰ 时间信息\n"+
"├ 创建时间:%s\n"+
@@ -273,9 +276,24 @@ func sendPaymentNotification(order *mdb.Orders) {
strings.ToUpper(order.Token),
order.TradeId,
order.OrderId,
+ networkDisplay(order.Network),
order.ReceiveAddress,
order.CreatedAt.ToDateTimeString(),
carbon.Now().ToDateTimeString(),
)
telegram.SendToBot(msg)
}
+
+func networkDisplay(n string) string {
+ switch n {
+ case mdb.NetworkTron:
+ return "Tron"
+ case mdb.NetworkSolana:
+ return "Solana"
+ default:
+ if n == "" {
+ return "Tron"
+ }
+ return strings.ToUpper(n)
+ }
+}
diff --git a/src/mq/worker.go b/src/mq/worker.go
index c18d763..9c11dea 100644
--- a/src/mq/worker.go
+++ b/src/mq/worker.go
@@ -23,6 +23,7 @@ const sqliteBusyRetryAttempts = 3
type expirableOrder struct {
ID uint64 `gorm:"column:id"`
TradeId string `gorm:"column:trade_id"`
+ Network string `gorm:"column:network"`
ReceiveAddress string `gorm:"column:receive_address"`
Token string `gorm:"column:token"`
ActualAmount float64 `gorm:"column:actual_amount"`
@@ -65,7 +66,7 @@ func processExpiredOrders() {
var orders []expirableOrder
err := withSQLiteBusyRetry(func() error {
return dao.Mdb.Model(&mdb.Orders{}).
- Select("id", "trade_id", "receive_address", "token", "actual_amount").
+ Select("id", "trade_id", "network", "receive_address", "token", "actual_amount").
Where("status = ?", mdb.StatusWaitPay).
Where("created_at <= ?", expirationCutoff).
Order("id asc").
@@ -89,7 +90,7 @@ func processExpiredOrders() {
if !expired {
continue
}
- if err = data.UnLockTransaction(order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
+ if err = data.UnLockTransaction(order.Network, order.ReceiveAddress, order.Token, order.ActualAmount); err != nil {
log.Sugar.Warnf("[mq] release expired transaction lock failed, trade_id=%s, err=%v", order.TradeId, err)
}
}
diff --git a/src/mq/worker_test.go b/src/mq/worker_test.go
index b67804e..e3813c9 100644
--- a/src/mq/worker_test.go
+++ b/src/mq/worker_test.go
@@ -27,6 +27,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
ActualAmount: 1,
ReceiveAddress: "wallet_1",
Token: "USDT",
+ Network: "tron",
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/callback",
}
@@ -36,7 +37,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
if err := dao.Mdb.Model(order).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil {
t.Fatalf("age expired order: %v", err)
}
- if err := data.LockTransaction(order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil {
+ if err := data.LockTransaction("tron", order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil {
t.Fatalf("lock expired order: %v", err)
}
@@ -48,13 +49,14 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
ActualAmount: 1.01,
ReceiveAddress: "wallet_1",
Token: "USDT",
+ Network: "tron",
Status: mdb.StatusWaitPay,
NotifyUrl: "https://merchant.example/callback",
}
if err := dao.Mdb.Create(recentOrder).Error; err != nil {
t.Fatalf("create recent order: %v", err)
}
- if err := data.LockTransaction(recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil {
+ if err := data.LockTransaction("tron", recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil {
t.Fatalf("lock recent order: %v", err)
}
@@ -67,7 +69,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
if expired.Status != mdb.StatusExpired {
t.Fatalf("expired order status = %d, want %d", expired.Status, mdb.StatusExpired)
}
- lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(order.ReceiveAddress, order.Token, order.ActualAmount)
+ lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken("tron", order.ReceiveAddress, order.Token, order.ActualAmount)
if err != nil {
t.Fatalf("expired order lock lookup: %v", err)
}
@@ -82,7 +84,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
if recent.Status != mdb.StatusWaitPay {
t.Fatalf("recent order status = %d, want %d", recent.Status, mdb.StatusWaitPay)
}
- lockTradeID, err = data.GetTradeIdByWalletAddressAndAmountAndToken(recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.ActualAmount)
+ lockTradeID, err = data.GetTradeIdByWalletAddressAndAmountAndToken("tron", recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.ActualAmount)
if err != nil {
t.Fatalf("recent order lock lookup: %v", err)
}
diff --git a/src/route/router.go b/src/route/router.go
index ce8e11b..8ee23db 100644
--- a/src/route/router.go
+++ b/src/route/router.go
@@ -40,7 +40,7 @@ func RegisterRoute(e *echo.Echo) {
body["currency"] = "cny"
}
if _, ok := body["network"]; !ok {
- body["network"] = "TRON"
+ body["network"] = "tron"
}
ctx.Set("request_body", body)
@@ -57,4 +57,11 @@ func RegisterRoute(e *echo.Echo) {
gmpayV1 := paymentRoute.Group("/gmpay/v1")
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
+ // wallet management routes
+ walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken())
+ walletV1.POST("/add", comm.Ctrl.AddWallet)
+ walletV1.GET("/list", comm.Ctrl.ListWallets)
+ walletV1.GET("/:id", comm.Ctrl.GetWallet)
+ walletV1.POST("/:id/status", comm.Ctrl.ChangeWalletStatus)
+ walletV1.POST("/:id/delete", comm.Ctrl.DeleteWallet)
}
diff --git a/src/route/router_test.go b/src/route/router_test.go
new file mode 100644
index 0000000..ddc0a5c
--- /dev/null
+++ b/src/route/router_test.go
@@ -0,0 +1,403 @@
+package route
+
+import (
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/assimon/luuu/model/dao"
+ "github.com/assimon/luuu/model/mdb"
+ "github.com/assimon/luuu/util/log"
+ "github.com/assimon/luuu/util/sign"
+ "github.com/labstack/echo/v4"
+ "github.com/spf13/viper"
+)
+
+const testAPIToken = "test-secret-token"
+
+func setupTestEnv(t *testing.T) *echo.Echo {
+ t.Helper()
+
+ tmpDir := t.TempDir()
+
+ // minimal viper config
+ viper.Reset()
+ viper.Set("db_type", "sqlite")
+ viper.Set("api_auth_token", testAPIToken)
+ viper.Set("app_uri", "http://localhost:8080")
+ viper.Set("order_expiration_time", 10)
+ viper.Set("api_rate_url", "")
+ viper.Set("forced_usdt_rate", 7.0)
+ viper.Set("runtime_root_path", tmpDir)
+ viper.Set("log_save_path", tmpDir)
+ viper.Set("sqlite_database_filename", tmpDir+"/test.db")
+ viper.Set("runtime_sqlite_filename", tmpDir+"/runtime.db")
+
+ log.Init()
+
+ // init config paths
+ os.Setenv("EPUSDT_CONFIG", tmpDir)
+ defer os.Unsetenv("EPUSDT_CONFIG")
+
+ // init DB
+ if err := dao.DBInit(); err != nil {
+ t.Fatalf("DBInit: %v", err)
+ }
+ if err := dao.RuntimeInit(); err != nil {
+ t.Fatalf("RuntimeInit: %v", err)
+ }
+
+ // ensure tables exist (MdbTableInit uses sync.Once, so migrate directly)
+ dao.Mdb.AutoMigrate(&mdb.Orders{}, &mdb.WalletAddress{})
+
+ // seed wallet addresses
+ dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable})
+ dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkSolana, Address: "SolTestAddress001", Status: mdb.TokenStatusEnable})
+
+ e := echo.New()
+ RegisterRoute(e)
+ return e
+}
+
+func signBody(body map[string]interface{}) map[string]interface{} {
+ sig, _ := sign.Get(body, testAPIToken)
+ body["signature"] = sig
+ return body
+}
+
+func doPost(e *echo.Echo, path string, body map[string]interface{}) *httptest.ResponseRecorder {
+ jsonBytes, _ := json.Marshal(body)
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes)))
+ req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+// TestCreateOrderEpusdtDefaultTron tests the epusdt compatibility route defaults to tron network.
+func TestCreateOrderEpusdtDefaultTron(t *testing.T) {
+ e := setupTestEnv(t)
+
+ body := signBody(map[string]interface{}{
+ "order_id": "test-tron-001",
+ "amount": 1.00,
+ "notify_url": "http://localhost/notify",
+ })
+
+ rec := doPost(e, "/payments/epusdt/v1/order/create-transaction", body)
+ t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String())
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rec.Code)
+ }
+
+ var resp map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ data, ok := resp["data"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("expected data in response, got: %v", resp)
+ }
+
+ if data["trade_id"] == nil || data["trade_id"] == "" {
+ t.Error("expected trade_id in response")
+ }
+ if data["receive_address"] != "TTestTronAddress001" {
+ t.Errorf("expected tron address, got: %v", data["receive_address"])
+ }
+ t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"])
+}
+
+// TestCreateOrderGmpayV1Solana tests the gmpay route with solana network.
+func TestCreateOrderGmpayV1Solana(t *testing.T) {
+ e := setupTestEnv(t)
+
+ body := signBody(map[string]interface{}{
+ "order_id": "test-sol-001",
+ "amount": 1.00,
+ "token": "usdt",
+ "currency": "cny",
+ "network": "solana",
+ "notify_url": "http://localhost/notify",
+ })
+
+ rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
+ t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String())
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", rec.Code)
+ }
+
+ var resp map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+
+ data, ok := resp["data"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("expected data in response, got: %v", resp)
+ }
+
+ if data["trade_id"] == nil || data["trade_id"] == "" {
+ t.Error("expected trade_id in response")
+ }
+ if data["receive_address"] != "SolTestAddress001" {
+ t.Errorf("expected solana address, got: %v", data["receive_address"])
+ }
+ t.Logf("Order created: trade_id=%v address=%v amount=%v", data["trade_id"], data["receive_address"], data["actual_amount"])
+}
+
+// TestCreateOrderGmpayV1SolNative tests creating an order for native SOL token.
+func TestCreateOrderGmpayV1SolNative(t *testing.T) {
+ e := setupTestEnv(t)
+
+ body := signBody(map[string]interface{}{
+ "order_id": "test-sol-native-001",
+ "amount": 0.05,
+ "token": "sol",
+ "currency": "usd",
+ "network": "solana",
+ "notify_url": "http://localhost/notify",
+ })
+
+ rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
+ t.Logf("Status: %d, Body: %s", rec.Code, rec.Body.String())
+
+ var resp map[string]interface{}
+ json.Unmarshal(rec.Body.Bytes(), &resp)
+ t.Logf("Response: %v", resp)
+
+ // This may fail if rate API is not configured, which is expected in test
+ // The important thing is the route accepts the request with network=solana token=sol
+ if rec.Code != http.StatusOK {
+ t.Logf("Note: non-200 may be expected if rate API is not configured for SOL")
+ }
+}
+
+func doGet(e *echo.Echo, path string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ req.Header.Set("Authorization", testAPIToken)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+func doPostWithToken(e *echo.Echo, path string, body map[string]interface{}) *httptest.ResponseRecorder {
+ jsonBytes, _ := json.Marshal(body)
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(jsonBytes)))
+ req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
+ req.Header.Set("Authorization", testAPIToken)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+ return rec
+}
+
+func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interface{} {
+ t.Helper()
+ var resp map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("unmarshal: %v", err)
+ }
+ return resp
+}
+
+// TestWalletAddAndList tests adding wallets via API and listing them.
+func TestWalletAddAndList(t *testing.T) {
+ e := setupTestEnv(t)
+
+ // Add a solana wallet
+ rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
+ "network": "solana",
+ "address": "NewSolWallet001",
+ })
+ t.Logf("Add: %s", rec.Body.String())
+ resp := parseResp(t, rec)
+ if resp["status_code"].(float64) != 200 {
+ t.Fatalf("add wallet failed: %v", resp)
+ }
+
+ // Add a tron wallet
+ rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
+ "network": "tron",
+ "address": "NewTronWallet001",
+ })
+ resp = parseResp(t, rec)
+ if resp["status_code"].(float64) != 200 {
+ t.Fatalf("add tron wallet failed: %v", resp)
+ }
+
+ // List all wallets
+ rec = doGet(e, "/payments/gmpay/v1/wallet/list")
+ resp = parseResp(t, rec)
+ wallets := resp["data"].([]interface{})
+ // 2 seeded + 2 added = 4
+ if len(wallets) != 4 {
+ t.Fatalf("expected 4 wallets, got %d: %v", len(wallets), wallets)
+ }
+
+ // List by network
+ rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=solana")
+ resp = parseResp(t, rec)
+ wallets = resp["data"].([]interface{})
+ if len(wallets) != 2 {
+ t.Fatalf("expected 2 solana wallets, got %d", len(wallets))
+ }
+
+ rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=tron")
+ resp = parseResp(t, rec)
+ wallets = resp["data"].([]interface{})
+ if len(wallets) != 2 {
+ t.Fatalf("expected 2 tron wallets, got %d", len(wallets))
+ }
+}
+
+// TestWalletDuplicateRejected tests that adding the same network+address twice fails.
+func TestWalletDuplicateRejected(t *testing.T) {
+ e := setupTestEnv(t)
+
+ body := map[string]interface{}{"network": "solana", "address": "DupWallet001"}
+ rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", body)
+ resp := parseResp(t, rec)
+ if resp["status_code"].(float64) != 200 {
+ t.Fatalf("first add failed: %v", resp)
+ }
+
+ // Same network+address should fail
+ rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", body)
+ resp = parseResp(t, rec)
+ if resp["status_code"].(float64) == 200 {
+ t.Fatal("expected duplicate to be rejected")
+ }
+ t.Logf("Duplicate rejected: %v", resp["message"])
+
+ // Same address, different network should succeed
+ rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
+ "network": "tron",
+ "address": "DupWallet001",
+ })
+ resp = parseResp(t, rec)
+ if resp["status_code"].(float64) != 200 {
+ t.Fatalf("same address on different network should succeed: %v", resp)
+ }
+}
+
+// TestWalletStatusAndDelete tests enable/disable/delete operations.
+func TestWalletStatusAndDelete(t *testing.T) {
+ e := setupTestEnv(t)
+
+ // Add a wallet
+ rec := doPostWithToken(e, "/payments/gmpay/v1/wallet/add", map[string]interface{}{
+ "network": "solana",
+ "address": "StatusTestWallet",
+ })
+ resp := parseResp(t, rec)
+ wallet := resp["data"].(map[string]interface{})
+ walletID := fmt.Sprintf("%.0f", wallet["id"].(float64))
+
+ // Get wallet
+ rec = doGet(e, "/payments/gmpay/v1/wallet/"+walletID)
+ resp = parseResp(t, rec)
+ if resp["status_code"].(float64) != 200 {
+ t.Fatalf("get wallet failed: %v", resp)
+ }
+
+ // Disable wallet
+ rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/"+walletID+"/status", map[string]interface{}{
+ "status": 2,
+ })
+ resp = parseResp(t, rec)
+ if resp["status_code"].(float64) != 200 {
+ t.Fatalf("disable wallet failed: %v", resp)
+ }
+
+ // Verify disabled — should not appear in available list
+ rec = doGet(e, "/payments/gmpay/v1/wallet/list?network=solana")
+ resp = parseResp(t, rec)
+ wallets := resp["data"].([]interface{})
+ for _, w := range wallets {
+ wm := w.(map[string]interface{})
+ if wm["address"] == "StatusTestWallet" && wm["status"].(float64) != 2 {
+ t.Error("wallet should be disabled")
+ }
+ }
+
+ // Delete wallet
+ rec = doPostWithToken(e, "/payments/gmpay/v1/wallet/"+walletID+"/delete", nil)
+ resp = parseResp(t, rec)
+ if resp["status_code"].(float64) != 200 {
+ t.Fatalf("delete wallet failed: %v", resp)
+ }
+
+ // Verify deleted
+ rec = doGet(e, "/payments/gmpay/v1/wallet/"+walletID)
+ resp = parseResp(t, rec)
+ // Should return not found
+ if resp["status_code"].(float64) == 200 {
+ data := resp["data"].(map[string]interface{})
+ if data["id"].(float64) > 0 {
+ t.Error("wallet should be deleted")
+ }
+ }
+}
+
+// TestWalletAuthRequired tests that wallet APIs require auth token.
+func TestWalletAuthRequired(t *testing.T) {
+ e := setupTestEnv(t)
+
+ // No auth header — should not return success
+ req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/wallet/list", nil)
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ // The response should indicate auth failure (not 200 success)
+ var resp map[string]interface{}
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ // echo may return plain text error
+ if rec.Code == http.StatusOK {
+ t.Error("expected auth failure without token")
+ }
+ t.Logf("Auth rejected (non-JSON): status=%d body=%s", rec.Code, rec.Body.String())
+ return
+ }
+ statusCode, _ := resp["status_code"].(float64)
+ if statusCode == 200 {
+ t.Error("expected auth failure without token")
+ }
+ t.Logf("Auth rejected: %v", resp)
+}
+
+// TestCreateOrderNetworkIsolation verifies tron and solana wallets don't mix.
+func TestCreateOrderNetworkIsolation(t *testing.T) {
+ e := setupTestEnv(t)
+
+ // Try to create a solana order — should get solana address, not tron
+ body := signBody(map[string]interface{}{
+ "order_id": fmt.Sprintf("test-isolation-%d", 1),
+ "amount": 1.00,
+ "token": "usdt",
+ "currency": "cny",
+ "network": "solana",
+ "notify_url": "http://localhost/notify",
+ })
+ rec := doPost(e, "/payments/gmpay/v1/order/create-transaction", body)
+
+ var resp map[string]interface{}
+ json.Unmarshal(rec.Body.Bytes(), &resp)
+
+ data, ok := resp["data"].(map[string]interface{})
+ if !ok {
+ t.Fatalf("expected data, got: %v", resp)
+ }
+ if data["receive_address"] == "TTestTronAddress001" {
+ t.Error("solana order should NOT get a tron address")
+ }
+ if data["receive_address"] != "SolTestAddress001" {
+ t.Errorf("expected SolTestAddress001, got %v", data["receive_address"])
+ }
+}
diff --git a/src/task/listen.go b/src/task/listen.go
index 3eb96ca..0ac3c83 100644
--- a/src/task/listen.go
+++ b/src/task/listen.go
@@ -15,6 +15,13 @@ func Start() {
return
}
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
+ // solana钱包监听
+ _, err = c.AddJob("@every 5s", ListenSolJob{})
+ if err != nil {
+ log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
+ return
+ }
+ log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)")
c.Start()
log.Sugar.Info("[task] Task scheduler started")
}
diff --git a/src/task/listen_sol_job.go b/src/task/listen_sol_job.go
new file mode 100644
index 0000000..ee05549
--- /dev/null
+++ b/src/task/listen_sol_job.go
@@ -0,0 +1,38 @@
+package task
+
+import (
+ "sync"
+
+ "github.com/assimon/luuu/model/data"
+ "github.com/assimon/luuu/model/mdb"
+ "github.com/assimon/luuu/model/service"
+ "github.com/assimon/luuu/util/log"
+)
+
+type ListenSolJob struct{}
+
+var gListenSolJobLock sync.Mutex
+
+func (r ListenSolJob) Run() {
+ gListenSolJobLock.Lock()
+ defer gListenSolJobLock.Unlock()
+ log.Sugar.Debug("[ListenSolJob] Job triggered")
+ walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkSolana)
+ if err != nil {
+ log.Sugar.Errorf("[ListenSolJob] Failed to get wallet addresses: %v", err)
+ return
+ }
+ if len(walletAddress) <= 0 {
+ log.Sugar.Debug("[ListenSolJob] No available wallet addresses")
+ return
+ }
+ log.Sugar.Infof("[ListenSolJob] Found %d wallet addresses to monitor", len(walletAddress))
+ var wg sync.WaitGroup
+ for _, address := range walletAddress {
+ log.Sugar.Infof("[ListenSolJob] Listening to address: %s", address.Address)
+ wg.Add(1)
+ go service.SolCallBack(address.Address, &wg)
+ }
+ wg.Wait()
+ log.Sugar.Debug("[ListenSolJob] Job completed")
+}
diff --git a/src/task/listen_trc20_job.go b/src/task/listen_trc20_job.go
index f35cc86..676d808 100644
--- a/src/task/listen_trc20_job.go
+++ b/src/task/listen_trc20_job.go
@@ -4,12 +4,12 @@ import (
"sync"
"github.com/assimon/luuu/model/data"
+ "github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
"github.com/assimon/luuu/util/log"
)
-type ListenTrc20Job struct {
-}
+type ListenTrc20Job struct{}
var gListenTrc20JobLock sync.Mutex
@@ -17,7 +17,7 @@ func (r ListenTrc20Job) Run() {
gListenTrc20JobLock.Lock()
defer gListenTrc20JobLock.Unlock()
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
- walletAddress, err := data.GetAvailableWalletAddress()
+ walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
if err != nil {
log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
return