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=
|
forced_usdt_rate=
|
||||||
api_rate_url=
|
api_rate_url=
|
||||||
tron_grid_api_key=
|
tron_grid_api_key=
|
||||||
|
solana_rpc_url=
|
||||||
|
|||||||
@@ -328,3 +328,11 @@ func GetCallbackRetryBaseDuration() time.Duration {
|
|||||||
}
|
}
|
||||||
return time.Duration(seconds) * time.Second
|
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 {
|
if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_token_amount_uindex").Error; err != nil {
|
||||||
return err
|
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 {
|
if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil {
|
||||||
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
|
color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -108,11 +108,12 @@ func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, e
|
|||||||
return result.RowsAffected > 0, result.Error
|
return result.RowsAffected > 0, result.Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by address, token and amount.
|
// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by network, address, token and amount.
|
||||||
func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) {
|
func GetTradeIdByWalletAddressAndAmountAndToken(network string, address string, token string, amount float64) (string, error) {
|
||||||
scaledAmount, _ := normalizeLockAmount(amount)
|
scaledAmount, _ := normalizeLockAmount(amount)
|
||||||
var lock mdb.TransactionLock
|
var lock mdb.TransactionLock
|
||||||
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
err := dao.RuntimeDB.Model(&mdb.TransactionLock{}).
|
||||||
|
Where("network = ?", network).
|
||||||
Where("address = ?", address).
|
Where("address = ?", address).
|
||||||
Where("token = ?", normalizeLockToken(token)).
|
Where("token = ?", normalizeLockToken(token)).
|
||||||
Where("amount_scaled = ?", scaledAmount).
|
Where("amount_scaled = ?", scaledAmount).
|
||||||
@@ -128,12 +129,13 @@ func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, am
|
|||||||
return lock.TradeId, nil
|
return lock.TradeId, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LockTransaction reserves an address+token+amount pair in sqlite until expiration.
|
// LockTransaction reserves a network+address+token+amount pair in sqlite until expiration.
|
||||||
func LockTransaction(address, token, tradeID string, amount float64, expirationTime time.Duration) error {
|
func LockTransaction(network, address, token, tradeID string, amount float64, expirationTime time.Duration) error {
|
||||||
scaledAmount, amountText := normalizeLockAmount(amount)
|
scaledAmount, amountText := normalizeLockAmount(amount)
|
||||||
normalizedToken := normalizeLockToken(token)
|
normalizedToken := normalizeLockToken(token)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
lock := &mdb.TransactionLock{
|
lock := &mdb.TransactionLock{
|
||||||
|
Network: network,
|
||||||
Address: address,
|
Address: address,
|
||||||
Token: normalizedToken,
|
Token: normalizedToken,
|
||||||
AmountScaled: scaledAmount,
|
AmountScaled: scaledAmount,
|
||||||
@@ -143,7 +145,8 @@ func LockTransaction(address, token, tradeID string, amount float64, expirationT
|
|||||||
}
|
}
|
||||||
|
|
||||||
return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error {
|
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("token = ?", normalizedToken).
|
||||||
Where("amount_scaled = ?", scaledAmount).
|
Where("amount_scaled = ?", scaledAmount).
|
||||||
Where("expires_at <= ?", now).
|
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.
|
// UnLockTransaction releases the reservation for network+address+token+amount.
|
||||||
func UnLockTransaction(address string, token string, amount float64) error {
|
func UnLockTransaction(network string, address string, token string, amount float64) error {
|
||||||
scaledAmount, _ := normalizeLockAmount(amount)
|
scaledAmount, _ := normalizeLockAmount(amount)
|
||||||
return dao.RuntimeDB.
|
return dao.RuntimeDB.
|
||||||
|
Where("network = ?", network).
|
||||||
Where("address = ?", address).
|
Where("address = ?", address).
|
||||||
Where("token = ?", normalizeLockToken(token)).
|
Where("token = ?", normalizeLockToken(token)).
|
||||||
Where("amount_scaled = ?", scaledAmount).
|
Where("amount_scaled = ?", scaledAmount).
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
package data
|
package data
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/dao"
|
"github.com/assimon/luuu/model/dao"
|
||||||
"github.com/assimon/luuu/model/mdb"
|
"github.com/assimon/luuu/model/mdb"
|
||||||
"github.com/assimon/luuu/util/constant"
|
"github.com/assimon/luuu/util/constant"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AddWalletAddress 创建钱包
|
// AddWalletAddress 创建钱包 (默认 tron 网络,用于 Telegram 添加)
|
||||||
func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -16,6 +25,7 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
|
|||||||
return nil, constant.WalletAddressAlreadyExists
|
return nil, constant.WalletAddressAlreadyExists
|
||||||
}
|
}
|
||||||
walletAddress := &mdb.WalletAddress{
|
walletAddress := &mdb.WalletAddress{
|
||||||
|
Network: network,
|
||||||
Address: address,
|
Address: address,
|
||||||
Status: mdb.TokenStatusEnable,
|
Status: mdb.TokenStatusEnable,
|
||||||
}
|
}
|
||||||
@@ -23,7 +33,17 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) {
|
|||||||
return walletAddress, err
|
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) {
|
func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) {
|
||||||
walletAddress := new(mdb.WalletAddress)
|
walletAddress := new(mdb.WalletAddress)
|
||||||
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error
|
err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error
|
||||||
@@ -50,6 +70,16 @@ func GetAvailableWalletAddress() ([]mdb.WalletAddress, error) {
|
|||||||
return WalletAddressList, err
|
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 获得所有钱包地址
|
// GetAllWalletAddress 获得所有钱包地址
|
||||||
func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
||||||
var WalletAddressList []mdb.WalletAddress
|
var WalletAddressList []mdb.WalletAddress
|
||||||
@@ -57,6 +87,13 @@ func GetAllWalletAddress() ([]mdb.WalletAddress, error) {
|
|||||||
return WalletAddressList, err
|
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 启用禁用钱包
|
// ChangeWalletAddressStatus 启用禁用钱包
|
||||||
func ChangeWalletAddressStatus(id uint64, status int) error {
|
func ChangeWalletAddressStatus(id uint64, status int) error {
|
||||||
err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Update("status", status).Error
|
err := dao.Mdb.Model(&mdb.WalletAddress{}).Where("id = ?", id).Update("status", status).Error
|
||||||
|
|||||||
@@ -4,9 +4,10 @@ import "time"
|
|||||||
|
|
||||||
type TransactionLock struct {
|
type TransactionLock struct {
|
||||||
ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
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"`
|
Network string `gorm:"column:network;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:1" json:"network"`
|
||||||
Token string `gorm:"column:token;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:2" json:"token"`
|
Address string `gorm:"column:address;uniqueIndex:transaction_lock_network_address_token_amount_uindex,priority:2" json:"address"`
|
||||||
AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:3" json:"amount_scaled"`
|
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"`
|
AmountText string `gorm:"column:amount_text" json:"amount_text"`
|
||||||
TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"`
|
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"`
|
ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"`
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ const (
|
|||||||
TokenStatusDisable = 2
|
TokenStatusDisable = 2
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
NetworkTron = "tron"
|
||||||
|
NetworkSolana = "solana"
|
||||||
|
)
|
||||||
|
|
||||||
type WalletAddress struct {
|
type WalletAddress struct {
|
||||||
Network string `gorm:"column:network;uniqueIndex:wallet_address_network_address_uindex" json:"network"`
|
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"`
|
Address string `gorm:"column:address;uniqueIndex:wallet_address_network_address_uindex" json:"address"`
|
||||||
|
|||||||
@@ -28,8 +28,10 @@ const (
|
|||||||
IncrementalMaximumNumber = 100
|
IncrementalMaximumNumber = 100
|
||||||
)
|
)
|
||||||
|
|
||||||
var gCreateTransactionLock sync.Mutex
|
var (
|
||||||
var gOrderProcessingLock sync.Mutex
|
gCreateTransactionLock sync.Mutex
|
||||||
|
gOrderProcessingLock sync.Mutex
|
||||||
|
)
|
||||||
|
|
||||||
// CreateTransaction creates a new payment order.
|
// CreateTransaction creates a new payment order.
|
||||||
func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateTransactionResponse, error) {
|
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))
|
token := strings.ToUpper(strings.TrimSpace(req.Token))
|
||||||
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
currency := strings.ToUpper(strings.TrimSpace(req.Currency))
|
||||||
|
network := strings.ToLower(strings.TrimSpace(req.Network))
|
||||||
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
|
payAmount := math.MustParsePrecFloat64(req.Amount, 2)
|
||||||
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency))
|
||||||
if rate <= 0 {
|
if rate <= 0 {
|
||||||
@@ -61,7 +64,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
|||||||
return nil, constant.OrderAlreadyExists
|
return nil, constant.OrderAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
walletAddress, err := data.GetAvailableWalletAddress()
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(network)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -71,7 +74,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
|||||||
|
|
||||||
tradeID := GenerateCode()
|
tradeID := GenerateCode()
|
||||||
amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -88,6 +91,7 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT
|
|||||||
ActualAmount: availableAmount,
|
ActualAmount: availableAmount,
|
||||||
ReceiveAddress: availableAddress,
|
ReceiveAddress: availableAddress,
|
||||||
Token: token,
|
Token: token,
|
||||||
|
Network: network,
|
||||||
Status: mdb.StatusWaitPay,
|
Status: mdb.StatusWaitPay,
|
||||||
NotifyUrl: req.NotifyUrl,
|
NotifyUrl: req.NotifyUrl,
|
||||||
RedirectUrl: req.RedirectUrl,
|
RedirectUrl: req.RedirectUrl,
|
||||||
@@ -148,20 +152,20 @@ func OrderProcessing(req *request.OrderProcessingRequest) error {
|
|||||||
return err
|
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)
|
log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReserveAvailableWalletAndAmount finds and locks an address+token+amount pair.
|
// ReserveAvailableWalletAndAmount finds and locks a network+address+token+amount pair.
|
||||||
func ReserveAvailableWalletAndAmount(tradeID string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
|
func ReserveAvailableWalletAndAmount(tradeID string, network string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) {
|
||||||
availableAddress := ""
|
availableAddress := ""
|
||||||
availableAmount := amount
|
availableAmount := amount
|
||||||
|
|
||||||
tryLockWalletFunc := func(targetAmount float64) (string, error) {
|
tryLockWalletFunc := func(targetAmount float64) (string, error) {
|
||||||
for _, address := range walletAddress {
|
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 {
|
if err == nil {
|
||||||
return address.Address, nil
|
return address.Address, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ func newCreateTransactionRequest(orderID string, amount float64) *request.Create
|
|||||||
OrderId: orderID,
|
OrderId: orderID,
|
||||||
Currency: "CNY",
|
Currency: "CNY",
|
||||||
Token: "USDT",
|
Token: "USDT",
|
||||||
|
Network: "tron",
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
NotifyUrl: "https://merchant.example/callback",
|
NotifyUrl: "https://merchant.example/callback",
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) {
|
|||||||
t.Fatalf("unexpected tokens: %s, %s", resp1.Token, resp2.Token)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("get first runtime lock: %v", err)
|
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)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("get second runtime lock: %v", err)
|
t.Fatalf("get second runtime lock: %v", err)
|
||||||
}
|
}
|
||||||
@@ -86,6 +87,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
|||||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||||
ReceiveAddress: resp.ReceiveAddress,
|
ReceiveAddress: resp.ReceiveAddress,
|
||||||
Token: resp.Token,
|
Token: resp.Token,
|
||||||
|
Network: "tron",
|
||||||
TradeId: resp.TradeId,
|
TradeId: resp.TradeId,
|
||||||
Amount: resp.ActualAmount,
|
Amount: resp.ActualAmount,
|
||||||
BlockTransactionId: "block_1",
|
BlockTransactionId: "block_1",
|
||||||
@@ -108,7 +110,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) {
|
|||||||
t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("get runtime lock after processing: %v", err)
|
t.Fatalf("get runtime lock after processing: %v", err)
|
||||||
}
|
}
|
||||||
@@ -133,6 +135,7 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) {
|
|||||||
req := &request.OrderProcessingRequest{
|
req := &request.OrderProcessingRequest{
|
||||||
ReceiveAddress: resp.ReceiveAddress,
|
ReceiveAddress: resp.ReceiveAddress,
|
||||||
Token: resp.Token,
|
Token: resp.Token,
|
||||||
|
Network: "tron",
|
||||||
TradeId: resp.TradeId,
|
TradeId: resp.TradeId,
|
||||||
Amount: resp.ActualAmount,
|
Amount: resp.ActualAmount,
|
||||||
BlockTransactionId: "block_1",
|
BlockTransactionId: "block_1",
|
||||||
@@ -180,6 +183,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) {
|
|||||||
err = OrderProcessing(&request.OrderProcessingRequest{
|
err = OrderProcessing(&request.OrderProcessingRequest{
|
||||||
ReceiveAddress: resp.ReceiveAddress,
|
ReceiveAddress: resp.ReceiveAddress,
|
||||||
Token: resp.Token,
|
Token: resp.Token,
|
||||||
|
Network: "tron",
|
||||||
TradeId: resp.TradeId,
|
TradeId: resp.TradeId,
|
||||||
Amount: resp.ActualAmount,
|
Amount: resp.ActualAmount,
|
||||||
BlockTransactionId: "block_expired",
|
BlockTransactionId: "block_expired",
|
||||||
@@ -239,6 +243,7 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) {
|
|||||||
errs <- OrderProcessing(&request.OrderProcessingRequest{
|
errs <- OrderProcessing(&request.OrderProcessingRequest{
|
||||||
ReceiveAddress: address,
|
ReceiveAddress: address,
|
||||||
Token: token,
|
Token: token,
|
||||||
|
Network: "tron",
|
||||||
TradeId: tradeID,
|
TradeId: tradeID,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
BlockTransactionId: "shared_block",
|
BlockTransactionId: "shared_block",
|
||||||
|
|||||||
+546
-170
@@ -1,22 +1,419 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"encoding/json"
|
||||||
"encoding/base64"
|
|
||||||
"encoding/binary"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"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"
|
||||||
"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 (
|
const (
|
||||||
// Mint token
|
// Mint token
|
||||||
USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
|
USDT_Mint = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB"
|
||||||
USDC_Mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"
|
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 (
|
const (
|
||||||
@@ -24,6 +421,49 @@ const (
|
|||||||
Token2022ProgramID = "TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb"
|
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) {
|
func FindATAAddress(owner, mint string) (string, error) {
|
||||||
ownerPubKey, err := solana.PublicKeyFromBase58(owner)
|
ownerPubKey, err := solana.PublicKeyFromBase58(owner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -43,191 +483,127 @@ func FindATAAddress(owner, mint string) (string, error) {
|
|||||||
return ata.String(), nil
|
return ata.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
// ParseTransferInfoFromInstruction 从单条指令中解析转账信息,非转账指令返回 nil
|
||||||
InstructionTransfer = 3
|
func ParseTransferInfoFromInstruction(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||||
InstructionTransferChecked = 12
|
programID := instruction.Get("programId").String()
|
||||||
)
|
parsedType := instruction.Get("parsed.type").String()
|
||||||
|
|
||||||
type TransferInfo struct {
|
if programID == SystemProgramID && parsedType == "transfer" {
|
||||||
ProgramID string
|
return parseSystemTransfer(instruction, txData)
|
||||||
Type string
|
}
|
||||||
|
|
||||||
Source string
|
if programID == TokenProgramID || programID == Token2022ProgramID {
|
||||||
Destination string
|
switch parsedType {
|
||||||
Mint string
|
case "transfer":
|
||||||
Authority string
|
return parseSplTransfer(instruction, txData)
|
||||||
|
case "transferChecked":
|
||||||
|
return parseSplTransferChecked(instruction, txData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Amount uint64
|
// Skip non-transfer instructions (ComputeBudget, AToken create, etc.)
|
||||||
Decimals *uint8
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isTokenProgram(programID solana.PublicKey) bool {
|
func parseSystemTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||||
p := programID.String()
|
info := instruction.Get("parsed.info")
|
||||||
return p == TokenProgramID || p == Token2022ProgramID
|
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) {
|
// parseSplTransfer 解析 SPL Token "transfer" 指令,mint 从 postTokenBalances 中查找
|
||||||
if len(data) < 9 {
|
func parseSplTransfer(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||||
return nil, fmt.Errorf("invalid transfer data length: %d", len(data))
|
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 {
|
// Look up mint and decimals from postTokenBalances using the destination ATA
|
||||||
return nil, fmt.Errorf("not transfer instruction, got: %d", data[0])
|
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 {
|
d := decimals
|
||||||
return nil, fmt.Errorf("transfer accounts not enough: %d", len(accountIndexes))
|
return &TransferInfo{
|
||||||
}
|
Source: source,
|
||||||
|
Destination: destination,
|
||||||
amount := binary.LittleEndian.Uint64(data[1:9])
|
Mint: mint,
|
||||||
|
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
|
||||||
sourceIdx := accountIndexes[0]
|
RawAmount: rawAmount.Uint64(),
|
||||||
destIdx := accountIndexes[1]
|
Decimals: &d,
|
||||||
authIdx := accountIndexes[2]
|
BlockTime: blockTime,
|
||||||
|
}, nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransferCheckedInfo struct {
|
// parseSplTransferChecked 解析 SPL Token "transferChecked" 指令
|
||||||
ProgramID string
|
func parseSplTransferChecked(instruction gjson.Result, txData []byte) (*TransferInfo, error) {
|
||||||
Type string
|
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
|
rawAmount, ok := new(big.Int).SetString(amountStr, 10)
|
||||||
Destination string
|
if !ok {
|
||||||
Mint string
|
return nil, fmt.Errorf("invalid amount: %s", amountStr)
|
||||||
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 &TransferInfo{
|
||||||
return nil, fmt.Errorf("not transferChecked instruction, got: %d", data[0])
|
Source: source,
|
||||||
}
|
Destination: destination,
|
||||||
|
Mint: mint,
|
||||||
if len(accountIndexes) < 4 {
|
Amount: ADJustAmount(rawAmount.Uint64(), decimals),
|
||||||
return nil, fmt.Errorf("transferChecked accounts not enough: %d", len(accountIndexes))
|
RawAmount: rawAmount.Uint64(),
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
Decimals: &decimals,
|
Decimals: &decimals,
|
||||||
}
|
BlockTime: blockTime,
|
||||||
|
}, nil
|
||||||
return info, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseTransactionTransfers
|
// findMintFromTokenBalances 从 postTokenBalances 中查找 ATA 对应的 mint 和 decimals
|
||||||
func ParseTransactionTransfers(ctx context.Context, client *rpc.Client, sig string) ([]*TransferInfo, error) {
|
func findMintFromTokenBalances(ataAddress string, txData []byte) (string, int, bool) {
|
||||||
signature, err := solana.SignatureFromBase58(sig)
|
accountKeys := gjson.GetBytes(txData, "result.transaction.message.accountKeys").Array()
|
||||||
if err != nil {
|
accountIndex := -1
|
||||||
return nil, fmt.Errorf("invalid signature: %w", err)
|
for i, key := range accountKeys {
|
||||||
}
|
if key.Get("pubkey").String() == ataAddress {
|
||||||
|
accountIndex = i
|
||||||
tx, err := client.GetTransaction(
|
break
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if accountIndex == -1 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
return results, nil
|
balances := gjson.GetBytes(txData, "result.meta.postTokenBalances").Array()
|
||||||
}
|
for _, balance := range balances {
|
||||||
|
if int(balance.Get("accountIndex").Int()) == accountIndex {
|
||||||
func toUint16Slice(in []uint16) []uint16 {
|
mint := balance.Get("mint").String()
|
||||||
out := make([]uint16, len(in))
|
decimals := int(balance.Get("uiTokenAmount.decimals").Int())
|
||||||
for i, v := range in {
|
return mint, decimals, true
|
||||||
out[i] = uint16(v)
|
}
|
||||||
}
|
}
|
||||||
return out
|
return "", 0, false
|
||||||
}
|
|
||||||
|
|
||||||
func DecodeBase64InstructionData(s string) ([]byte, error) {
|
|
||||||
return base64.StdEncoding.DecodeString(s)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,349 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gagliardetto/solana-go/rpc"
|
"github.com/tidwall/gjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseSolTransaction(t *testing.T) {
|
func TestSolClientHealthy(t *testing.T) {
|
||||||
client := rpc.New("https://api.mainnet-beta.solana.com")
|
bodyData, err := SolRetryClient("getHealth", nil)
|
||||||
sig := "3tZTwLrvmiZ59h4UzyMHPd7DPux7t9eXZgkUvEfquaoSuERrPSRNzWuSHKQM2fbiCWFDGNqoLpu2kLZnfoegVpqN"
|
|
||||||
|
|
||||||
txInfo, err := ParseTransactionTransfers(context.Background(), client, sig)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
t.Fatalf("SolRetryClient failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, item := range txInfo {
|
var result map[string]interface{}
|
||||||
fmt.Println("type =", item.Type)
|
err = json.Unmarshal(bodyData, &result)
|
||||||
fmt.Println("program =", item.ProgramID)
|
if err != nil {
|
||||||
fmt.Println("from =", item.Source)
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||||
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("-----")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestFindATAAddress(t *testing.T) {
|
||||||
owner := "2uFTf9TZ8gd7Kg6hkb79TxfaeNpaAgpJ8uVHguv2Yweu"
|
tests := []struct {
|
||||||
mint := "4k3Dyjzvzp8eMZWUXbBCjEvwSkkk59S5iCNLY3QrkX6R" // ray token
|
name string
|
||||||
|
owner string
|
||||||
ata, err := FindATAAddress(owner, mint)
|
mint string
|
||||||
if err != nil {
|
want string
|
||||||
panic(err)
|
}{
|
||||||
|
{
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
txID := transfer.Get("txID").String()
|
txID := transfer.Get("txID").String()
|
||||||
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount)
|
tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, "TRX", amount)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -134,6 +134,7 @@ func checkTrxTransfers(address string, wg *sync.WaitGroup) {
|
|||||||
req := &request.OrderProcessingRequest{
|
req := &request.OrderProcessingRequest{
|
||||||
ReceiveAddress: address,
|
ReceiveAddress: address,
|
||||||
Token: "TRX",
|
Token: "TRX",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
TradeId: tradeID,
|
TradeId: tradeID,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
BlockTransactionId: txID,
|
BlockTransactionId: txID,
|
||||||
@@ -212,7 +213,7 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
txID := transfer.Get("transaction_id").String()
|
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 {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
@@ -236,6 +237,7 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
|
|||||||
req := &request.OrderProcessingRequest{
|
req := &request.OrderProcessingRequest{
|
||||||
ReceiveAddress: address,
|
ReceiveAddress: address,
|
||||||
Token: "USDT",
|
Token: "USDT",
|
||||||
|
Network: mdb.NetworkTron,
|
||||||
TradeId: tradeID,
|
TradeId: tradeID,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
BlockTransactionId: txID,
|
BlockTransactionId: txID,
|
||||||
@@ -263,6 +265,7 @@ func sendPaymentNotification(order *mdb.Orders) {
|
|||||||
"📋 <b>订单信息</b>\n"+
|
"📋 <b>订单信息</b>\n"+
|
||||||
"├ 交易号:<code>%s</code>\n"+
|
"├ 交易号:<code>%s</code>\n"+
|
||||||
"├ 订单号:<code>%s</code>\n"+
|
"├ 订单号:<code>%s</code>\n"+
|
||||||
|
"├ 网络:<code>%s</code>\n"+
|
||||||
"└ 钱包地址:<code>%s</code>\n\n"+
|
"└ 钱包地址:<code>%s</code>\n\n"+
|
||||||
"⏰ <b>时间信息</b>\n"+
|
"⏰ <b>时间信息</b>\n"+
|
||||||
"├ 创建时间:%s\n"+
|
"├ 创建时间:%s\n"+
|
||||||
@@ -273,9 +276,24 @@ func sendPaymentNotification(order *mdb.Orders) {
|
|||||||
strings.ToUpper(order.Token),
|
strings.ToUpper(order.Token),
|
||||||
order.TradeId,
|
order.TradeId,
|
||||||
order.OrderId,
|
order.OrderId,
|
||||||
|
networkDisplay(order.Network),
|
||||||
order.ReceiveAddress,
|
order.ReceiveAddress,
|
||||||
order.CreatedAt.ToDateTimeString(),
|
order.CreatedAt.ToDateTimeString(),
|
||||||
carbon.Now().ToDateTimeString(),
|
carbon.Now().ToDateTimeString(),
|
||||||
)
|
)
|
||||||
telegram.SendToBot(msg)
|
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 {
|
type expirableOrder struct {
|
||||||
ID uint64 `gorm:"column:id"`
|
ID uint64 `gorm:"column:id"`
|
||||||
TradeId string `gorm:"column:trade_id"`
|
TradeId string `gorm:"column:trade_id"`
|
||||||
|
Network string `gorm:"column:network"`
|
||||||
ReceiveAddress string `gorm:"column:receive_address"`
|
ReceiveAddress string `gorm:"column:receive_address"`
|
||||||
Token string `gorm:"column:token"`
|
Token string `gorm:"column:token"`
|
||||||
ActualAmount float64 `gorm:"column:actual_amount"`
|
ActualAmount float64 `gorm:"column:actual_amount"`
|
||||||
@@ -65,7 +66,7 @@ func processExpiredOrders() {
|
|||||||
var orders []expirableOrder
|
var orders []expirableOrder
|
||||||
err := withSQLiteBusyRetry(func() error {
|
err := withSQLiteBusyRetry(func() error {
|
||||||
return dao.Mdb.Model(&mdb.Orders{}).
|
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("status = ?", mdb.StatusWaitPay).
|
||||||
Where("created_at <= ?", expirationCutoff).
|
Where("created_at <= ?", expirationCutoff).
|
||||||
Order("id asc").
|
Order("id asc").
|
||||||
@@ -89,7 +90,7 @@ func processExpiredOrders() {
|
|||||||
if !expired {
|
if !expired {
|
||||||
continue
|
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)
|
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,
|
ActualAmount: 1,
|
||||||
ReceiveAddress: "wallet_1",
|
ReceiveAddress: "wallet_1",
|
||||||
Token: "USDT",
|
Token: "USDT",
|
||||||
|
Network: "tron",
|
||||||
Status: mdb.StatusWaitPay,
|
Status: mdb.StatusWaitPay,
|
||||||
NotifyUrl: "https://merchant.example/callback",
|
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 {
|
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)
|
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)
|
t.Fatalf("lock expired order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,13 +49,14 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
ActualAmount: 1.01,
|
ActualAmount: 1.01,
|
||||||
ReceiveAddress: "wallet_1",
|
ReceiveAddress: "wallet_1",
|
||||||
Token: "USDT",
|
Token: "USDT",
|
||||||
|
Network: "tron",
|
||||||
Status: mdb.StatusWaitPay,
|
Status: mdb.StatusWaitPay,
|
||||||
NotifyUrl: "https://merchant.example/callback",
|
NotifyUrl: "https://merchant.example/callback",
|
||||||
}
|
}
|
||||||
if err := dao.Mdb.Create(recentOrder).Error; err != nil {
|
if err := dao.Mdb.Create(recentOrder).Error; err != nil {
|
||||||
t.Fatalf("create recent order: %v", err)
|
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)
|
t.Fatalf("lock recent order: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,7 +69,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
if expired.Status != mdb.StatusExpired {
|
if expired.Status != mdb.StatusExpired {
|
||||||
t.Fatalf("expired order status = %d, want %d", 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 {
|
if err != nil {
|
||||||
t.Fatalf("expired order lock lookup: %v", err)
|
t.Fatalf("expired order lock lookup: %v", err)
|
||||||
}
|
}
|
||||||
@@ -82,7 +84,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T)
|
|||||||
if recent.Status != mdb.StatusWaitPay {
|
if recent.Status != mdb.StatusWaitPay {
|
||||||
t.Fatalf("recent order status = %d, want %d", 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 {
|
if err != nil {
|
||||||
t.Fatalf("recent order lock lookup: %v", err)
|
t.Fatalf("recent order lock lookup: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-1
@@ -40,7 +40,7 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
body["currency"] = "cny"
|
body["currency"] = "cny"
|
||||||
}
|
}
|
||||||
if _, ok := body["network"]; !ok {
|
if _, ok := body["network"]; !ok {
|
||||||
body["network"] = "TRON"
|
body["network"] = "tron"
|
||||||
}
|
}
|
||||||
ctx.Set("request_body", body)
|
ctx.Set("request_body", body)
|
||||||
|
|
||||||
@@ -57,4 +57,11 @@ func RegisterRoute(e *echo.Echo) {
|
|||||||
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
gmpayV1 := paymentRoute.Group("/gmpay/v1")
|
||||||
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
|
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
|
return
|
||||||
}
|
}
|
||||||
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
|
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()
|
c.Start()
|
||||||
log.Sugar.Info("[task] Task scheduler started")
|
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"
|
"sync"
|
||||||
|
|
||||||
"github.com/assimon/luuu/model/data"
|
"github.com/assimon/luuu/model/data"
|
||||||
|
"github.com/assimon/luuu/model/mdb"
|
||||||
"github.com/assimon/luuu/model/service"
|
"github.com/assimon/luuu/model/service"
|
||||||
"github.com/assimon/luuu/util/log"
|
"github.com/assimon/luuu/util/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ListenTrc20Job struct {
|
type ListenTrc20Job struct{}
|
||||||
}
|
|
||||||
|
|
||||||
var gListenTrc20JobLock sync.Mutex
|
var gListenTrc20JobLock sync.Mutex
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ func (r ListenTrc20Job) Run() {
|
|||||||
gListenTrc20JobLock.Lock()
|
gListenTrc20JobLock.Lock()
|
||||||
defer gListenTrc20JobLock.Unlock()
|
defer gListenTrc20JobLock.Unlock()
|
||||||
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
|
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
|
||||||
walletAddress, err := data.GetAvailableWalletAddress()
|
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
|
log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
|
||||||
return
|
return
|
||||||
|
|||||||
Reference in New Issue
Block a user