mirror of
https://github.com/GMWalletApp/epusdt.git
synced 2026-07-07 02:06:16 +00:00
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
This commit is contained in:
@@ -61,3 +61,4 @@ order_notice_max_retry=0
|
||||
forced_usdt_rate=
|
||||
api_rate_url=
|
||||
tron_grid_api_key=
|
||||
solana_rpc_url=
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
+544
-168
@@ -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
|
||||
|
||||
Source string
|
||||
Destination string
|
||||
Mint string
|
||||
Authority string
|
||||
|
||||
Amount uint64
|
||||
Decimals *uint8
|
||||
if programID == SystemProgramID && parsedType == "transfer" {
|
||||
return parseSystemTransfer(instruction, txData)
|
||||
}
|
||||
|
||||
func isTokenProgram(programID solana.PublicKey) bool {
|
||||
p := programID.String()
|
||||
return p == TokenProgramID || p == Token2022ProgramID
|
||||
if programID == TokenProgramID || programID == Token2022ProgramID {
|
||||
switch parsedType {
|
||||
case "transfer":
|
||||
return parseSplTransfer(instruction, txData)
|
||||
case "transferChecked":
|
||||
return parseSplTransferChecked(instruction, txData)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
// Skip non-transfer instructions (ComputeBudget, AToken create, etc.)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if data[0] != InstructionTransfer {
|
||||
return nil, fmt.Errorf("not transfer instruction, got: %d", data[0])
|
||||
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
|
||||
}
|
||||
|
||||
if len(accountIndexes) < 3 {
|
||||
return nil, fmt.Errorf("transfer accounts not enough: %d", len(accountIndexes))
|
||||
// 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)
|
||||
}
|
||||
|
||||
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")
|
||||
// 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)
|
||||
}
|
||||
|
||||
info := &TransferInfo{
|
||||
ProgramID: programID.String(),
|
||||
Type: "transfer",
|
||||
Source: accountKeys[sourceIdx].String(),
|
||||
Destination: accountKeys[destIdx].String(),
|
||||
Authority: accountKeys[authIdx].String(),
|
||||
Amount: amount,
|
||||
d := decimals
|
||||
return &TransferInfo{
|
||||
Source: source,
|
||||
Destination: destination,
|
||||
Mint: mint,
|
||||
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
|
||||
RawAmount: rawAmount.Uint64(),
|
||||
Decimals: &d,
|
||||
BlockTime: blockTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return info, nil
|
||||
// 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()
|
||||
|
||||
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("invalid amount: %s", amountStr)
|
||||
}
|
||||
|
||||
type TransferCheckedInfo struct {
|
||||
ProgramID string
|
||||
Type string
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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,
|
||||
BlockTime: blockTime,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return info, nil
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
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 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)
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
var result map[string]interface{}
|
||||
err = json.Unmarshal(bodyData, &result)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
fmt.Println("-----")
|
||||
|
||||
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) {
|
||||
tests := []struct {
|
||||
name string
|
||||
owner string
|
||||
mint string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "RAY token ATA",
|
||||
owner: "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu",
|
||||
mint: "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R",
|
||||
want: "GgmJrwuP946uV8qAwsnXxzYrJqEwW6eGnsVnQZFS5rp4",
|
||||
},
|
||||
}
|
||||
|
||||
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"
|
||||
|
||||
ata, err := FindATAAddress(owner, mint)
|
||||
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 {
|
||||
panic(err)
|
||||
t.Fatalf("FindATAAddress failed: %v", err)
|
||||
}
|
||||
|
||||
fmt.Println("ATA =", ata)
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
"📋 <b>订单信息</b>\n"+
|
||||
"├ 交易号:<code>%s</code>\n"+
|
||||
"├ 订单号:<code>%s</code>\n"+
|
||||
"├ 网络:<code>%s</code>\n"+
|
||||
"└ 钱包地址:<code>%s</code>\n\n"+
|
||||
"⏰ <b>时间信息</b>\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)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+8
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user