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