diff --git a/src/.env.example b/src/.env.example index 9c3b098..c429455 100644 --- a/src/.env.example +++ b/src/.env.example @@ -57,3 +57,5 @@ api_auth_token= order_expiration_time=10 order_notice_max_retry=0 forced_usdt_rate= +api_rate_url= +tron_grid_api_key= diff --git a/src/config/config.go b/src/config/config.go index a13d7c0..cfe30b0 100644 --- a/src/config/config.go +++ b/src/config/config.go @@ -2,27 +2,33 @@ package config import ( "fmt" + "log" "net/url" "os" "path/filepath" + "strings" "time" + "github.com/assimon/luuu/util/http_client" "github.com/spf13/viper" + "github.com/tidwall/gjson" ) var ( - AppDebug bool - MysqlDns string - RuntimePath string - LogSavePath string - StaticPath string - TgBotToken string - TgProxy string - TgManage int64 - UsdtRate float64 - BuildVersion = "0.0.0-dev" - BuildCommit = "none" - BuildDate = "unknown" + AppDebug bool + MysqlDns string + RuntimePath string + LogSavePath string + StaticPath string + TgBotToken string + TgProxy string + TgManage int64 + UsdtRate float64 + RateApiUrl string + TRON_GRID_API_KEY string + BuildVersion = "0.0.0-dev" + BuildCommit = "none" + BuildDate = "unknown" ) func Init() { @@ -38,27 +44,21 @@ func Init() { } AppDebug = viper.GetBool("app_debug") StaticPath = viper.GetString("static_path") - RuntimePath = fmt.Sprintf( - "%s%s", - gwd, - viper.GetString("runtime_root_path")) - LogSavePath = fmt.Sprintf( - "%s%s", - RuntimePath, - viper.GetString("log_save_path")) + RuntimePath = fmt.Sprintf("%s%s", gwd, viper.GetString("runtime_root_path")) + LogSavePath = fmt.Sprintf("%s%s", RuntimePath, viper.GetString("log_save_path")) mustMkdir(RuntimePath) mustMkdir(LogSavePath) MysqlDns = fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", url.QueryEscape(viper.GetString("mysql_user")), url.QueryEscape(viper.GetString("mysql_passwd")), - fmt.Sprintf( - "%s:%s", - viper.GetString("mysql_host"), - viper.GetString("mysql_port")), + fmt.Sprintf("%s:%s", viper.GetString("mysql_host"), viper.GetString("mysql_port")), viper.GetString("mysql_database")) TgBotToken = viper.GetString("tg_bot_token") TgProxy = viper.GetString("tg_proxy") TgManage = viper.GetInt64("tg_manage") + + RateApiUrl = GetRateApiUrl() + TRON_GRID_API_KEY = viper.GetString("tron_grid_api_key") } func mustMkdir(path string) { @@ -95,6 +95,73 @@ func GetApiAuthToken() string { return viper.GetString("api_auth_token") } +func GetRateApiUrl() string { + rateURL := viper.GetString("api_rate_url") + if rateURL == "" { + rateURL = os.Getenv("API_RATE_URL") + } + if rateURL == "" { + log.Println("api_rate_url is empty") + } + RateApiUrl = rateURL + return rateURL +} + +func GetRateForCoin(coin string, base string) float64 { + coin = strings.ToLower(strings.TrimSpace(coin)) + base = strings.ToLower(strings.TrimSpace(base)) + if coin == "" || base == "" { + return 0 + } + if coin == base { + return 1 + } + if coin == "usdt" { + switch base { + case "usd": + return 1 + case "cny": + usdtRate := GetUsdtRate() + if usdtRate > 0 { + return 1 / usdtRate + } + } + } + + baseURL := RateApiUrl + if baseURL == "" { + baseURL = GetRateApiUrl() + } + if baseURL == "" { + log.Printf("rate api url is empty") + return 0.0 + } + if baseURL[len(baseURL)-1] != '/' { + baseURL += "/" + } + + client := http_client.GetHttpClient() + resp, err := client.R().Get(baseURL + fmt.Sprintf("%s.json", base)) + if err != nil { + log.Printf("call rate api error: %s", err.Error()) + return 0.0 + } + if resp.StatusCode() < 200 || resp.StatusCode() >= 300 { + log.Printf("call rate api unexpected status: %s", resp.Status()) + return 0.0 + } + + targetRate := 0.0 + gjson.GetBytes(resp.Body(), base).ForEach(func(key, value gjson.Result) bool { + if key.String() == coin { + targetRate = value.Float() + return false + } + return true + }) + return targetRate +} + func GetUsdtRate() float64 { forcedUsdtRate := viper.GetFloat64("forced_usdt_rate") if forcedUsdtRate > 0 { diff --git a/src/crypto/tron.go b/src/crypto/tron.go new file mode 100644 index 0000000..abf36f3 --- /dev/null +++ b/src/crypto/tron.go @@ -0,0 +1,30 @@ +package crypto + +import ( + "crypto/sha256" + + "github.com/shengdoushi/base58" +) + +const AddressLength = 20 +const PrefixMainnet = 0x41 + +// Encode returns the Base58 encoding of input using the Bitcoin alphabet. +func Encode(input []byte) string { + return base58.Encode(input, base58.BitcoinAlphabet) +} + +// EncodeCheck returns the Base58Check encoding of input (Base58 with a 4-byte SHA-256d checksum). +func EncodeCheck(input []byte) string { + h256h0 := sha256.New() + h256h0.Write(input) + h0 := h256h0.Sum(nil) + + h256h1 := sha256.New() + h256h1.Write(h0) + h1 := h256h1.Sum(nil) + + inputCheck := append(append([]byte(nil), input...), h1[:4]...) + + return Encode(inputCheck) +} diff --git a/src/go.mod b/src/go.mod index 08ea7df..2331914 100644 --- a/src/go.mod +++ b/src/go.mod @@ -15,9 +15,11 @@ require ( github.com/natefinch/lumberjack v2.0.0+incompatible github.com/robfig/cron/v3 v3.0.1 github.com/satori/go.uuid v1.2.0 + github.com/shengdoushi/base58 v1.0.0 github.com/shopspring/decimal v1.3.1 github.com/spf13/cobra v1.2.1 github.com/spf13/viper v1.9.0 + github.com/tidwall/gjson v1.18.0 go.uber.org/zap v1.17.0 gopkg.in/telebot.v3 v3.0.0 gorm.io/driver/mysql v1.5.1 @@ -58,6 +60,8 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.2.0 // indirect + github.com/tidwall/match v1.2.0 // indirect + github.com/tidwall/pretty v1.2.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.1 // indirect github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect diff --git a/src/go.sum b/src/go.sum index 472a06c..e62c8e7 100644 --- a/src/go.sum +++ b/src/go.sum @@ -360,6 +360,8 @@ github.com/sagikazarmark/crypt v0.1.0/go.mod h1:B/mN0msZuINBtQ1zZLEQcegFJJf9vnYI github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/shengdoushi/base58 v1.0.0 h1:tGe4o6TmdXFJWoI31VoSWvuaKxf0Px3gqa3sUWhAxBs= +github.com/shengdoushi/base58 v1.0.0/go.mod h1:m5uIILfzcKMw6238iWAhP4l3s5+uXyF3+bJKUNhAL9I= github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= @@ -393,6 +395,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/match v1.2.0 h1:0pt8FlkOwjN2fPt4bIl4BoNxb98gGHN2ObFEDkrfZnM= +github.com/tidwall/match v1.2.0/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8= diff --git a/src/model/dao/mdb_sqlite_cgo.go b/src/model/dao/mdb_sqlite_cgo.go index e9c7460..da9e05d 100644 --- a/src/model/dao/mdb_sqlite_cgo.go +++ b/src/model/dao/mdb_sqlite_cgo.go @@ -8,5 +8,9 @@ import ( ) func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) { - return gorm.Open(sqlite.Open(dsn), cfg) + db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg) + if err != nil { + return nil, err + } + return db, nil } diff --git a/src/model/dao/mdb_sqlite_pure.go b/src/model/dao/mdb_sqlite_pure.go index 666a9ca..2f1e9ec 100644 --- a/src/model/dao/mdb_sqlite_pure.go +++ b/src/model/dao/mdb_sqlite_pure.go @@ -8,5 +8,9 @@ import ( ) func openDB(dsn string, cfg *gorm.Config) (*gorm.DB, error) { - return gorm.Open(sqlite.Open(dsn), cfg) + db, err := gorm.Open(sqlite.Open(dsn+"?_journal_mode=WAL&_busy_timeout=5000"), cfg) + if err != nil { + return nil, err + } + return db, nil } diff --git a/src/model/dao/runtime_sqlite.go b/src/model/dao/runtime_sqlite.go index 2a8aa58..29a3597 100644 --- a/src/model/dao/runtime_sqlite.go +++ b/src/model/dao/runtime_sqlite.go @@ -56,6 +56,9 @@ func RuntimeInit() error { color.Red.Printf("[runtime_db] sqlite connDB err:%s", err.Error()) return err } + if err = RuntimeDB.Exec("DROP INDEX IF EXISTS transaction_lock_token_amount_uindex").Error; err != nil { + return err + } if err = RuntimeDB.AutoMigrate(&mdb.TransactionLock{}); err != nil { color.Red.Printf("[runtime_db] sqlite migrate DB(TransactionLock),err=%s\n", err) return err diff --git a/src/model/data/order_data.go b/src/model/data/order_data.go index 1de5612..f5484f8 100644 --- a/src/model/data/order_data.go +++ b/src/model/data/order_data.go @@ -2,6 +2,7 @@ package data import ( "errors" + "strings" "time" "github.com/assimon/luuu/model/dao" @@ -19,6 +20,10 @@ func normalizeLockAmount(amount float64) (int64, string) { return value.Shift(2).IntPart(), value.StringFixed(2) } +func normalizeLockToken(token string) string { + return strings.ToUpper(strings.TrimSpace(token)) +} + // GetOrderInfoByOrderId fetches an order by merchant order id. func GetOrderInfoByOrderId(orderId string) (*mdb.Orders, error) { order := new(mdb.Orders) @@ -39,9 +44,9 @@ func CreateOrderWithTransaction(tx *gorm.DB, order *mdb.Orders) error { } // GetOrderByBlockIdWithTransaction fetches an order by blockchain tx id. -func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockId string) (*mdb.Orders, error) { +func GetOrderByBlockIdWithTransaction(tx *gorm.DB, blockID string) (*mdb.Orders, error) { order := new(mdb.Orders) - err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockId).Error + err := tx.Model(order).Limit(1).Find(order, "block_transaction_id = ?", blockID).Error return order, err } @@ -94,12 +99,13 @@ func UpdateOrderIsExpirationById(id uint64, expirationCutoff time.Time) (bool, e return result.RowsAffected > 0, result.Error } -// GetTradeIdByWalletAddressAndAmount resolves the reserved trade id by token and amount. -func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, error) { +// GetTradeIdByWalletAddressAndAmountAndToken resolves the reserved trade id by address, token and amount. +func GetTradeIdByWalletAddressAndAmountAndToken(address string, token string, amount float64) (string, error) { scaledAmount, _ := normalizeLockAmount(amount) var lock mdb.TransactionLock err := dao.RuntimeDB.Model(&mdb.TransactionLock{}). - Where("token = ?", token). + Where("address = ?", address). + Where("token = ?", normalizeLockToken(token)). Where("amount_scaled = ?", scaledAmount). Where("expires_at > ?", time.Now()). Limit(1). @@ -113,26 +119,29 @@ func GetTradeIdByWalletAddressAndAmount(token string, amount float64) (string, e return lock.TradeId, nil } -// LockTransaction reserves a token+amount pair in sqlite until expiration. -func LockTransaction(token, tradeId string, amount float64, expirationTime time.Duration) error { +// LockTransaction reserves an address+token+amount pair in sqlite until expiration. +func LockTransaction(address, token, tradeID string, amount float64, expirationTime time.Duration) error { scaledAmount, amountText := normalizeLockAmount(amount) + normalizedToken := normalizeLockToken(token) now := time.Now() lock := &mdb.TransactionLock{ - Token: token, + Address: address, + Token: normalizedToken, AmountScaled: scaledAmount, AmountText: amountText, - TradeId: tradeId, + TradeId: tradeID, ExpiresAt: now.Add(expirationTime), } return dao.RuntimeDB.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("token = ?", token). + if err := tx.Where("address = ?", address). + Where("token = ?", normalizedToken). Where("amount_scaled = ?", scaledAmount). Where("expires_at <= ?", now). Delete(&mdb.TransactionLock{}).Error; err != nil { return err } - if err := tx.Where("trade_id = ?", tradeId).Delete(&mdb.TransactionLock{}).Error; err != nil { + if err := tx.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error; err != nil { return err } @@ -147,14 +156,18 @@ func LockTransaction(token, tradeId string, amount float64, expirationTime time. }) } -// UnLockTransaction releases the reservation for token+amount. -func UnLockTransaction(token string, amount float64) error { +// UnLockTransaction releases the reservation for address+token+amount. +func UnLockTransaction(address string, token string, amount float64) error { scaledAmount, _ := normalizeLockAmount(amount) - return dao.RuntimeDB.Where("token = ?", token).Where("amount_scaled = ?", scaledAmount).Delete(&mdb.TransactionLock{}).Error + return dao.RuntimeDB. + Where("address = ?", address). + Where("token = ?", normalizeLockToken(token)). + Where("amount_scaled = ?", scaledAmount). + Delete(&mdb.TransactionLock{}).Error } -func UnLockTransactionByTradeId(tradeId string) error { - return dao.RuntimeDB.Where("trade_id = ?", tradeId).Delete(&mdb.TransactionLock{}).Error +func UnLockTransactionByTradeId(tradeID string) error { + return dao.RuntimeDB.Where("trade_id = ?", tradeID).Delete(&mdb.TransactionLock{}).Error } func CleanupExpiredTransactionLocks() error { diff --git a/src/model/data/wallet_address_data.go b/src/model/data/wallet_address_data.go index eea107f..40d9b11 100644 --- a/src/model/data/wallet_address_data.go +++ b/src/model/data/wallet_address_data.go @@ -7,8 +7,8 @@ import ( ) // AddWalletAddress 创建钱包 -func AddWalletAddress(token string) (*mdb.WalletAddress, error) { - exist, err := GetWalletAddressByToken(token) +func AddWalletAddress(address string) (*mdb.WalletAddress, error) { + exist, err := GetWalletAddressByToken(address) if err != nil { return nil, err } @@ -16,17 +16,17 @@ func AddWalletAddress(token string) (*mdb.WalletAddress, error) { return nil, constant.WalletAddressAlreadyExists } walletAddress := &mdb.WalletAddress{ - Token: token, - Status: mdb.TokenStatusEnable, + Address: address, + Status: mdb.TokenStatusEnable, } err = dao.Mdb.Create(walletAddress).Error return walletAddress, err } -// GetWalletAddressByToken 通过钱包地址获取token -func GetWalletAddressByToken(token string) (*mdb.WalletAddress, error) { +// GetWalletAddressByToken 通过钱包地址获取address +func GetWalletAddressByToken(address string) (*mdb.WalletAddress, error) { walletAddress := new(mdb.WalletAddress) - err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "token = ?", token).Error + err := dao.Mdb.Model(walletAddress).Limit(1).Find(walletAddress, "address = ?", address).Error return walletAddress, err } diff --git a/src/model/mdb/orders_mdb.go b/src/model/mdb/orders_mdb.go index 0083d43..6ed6335 100644 --- a/src/model/mdb/orders_mdb.go +++ b/src/model/mdb/orders_mdb.go @@ -13,7 +13,9 @@ type Orders struct { OrderId string `gorm:"column:order_id;uniqueIndex:orders_order_id_uindex" json:"order_id"` BlockTransactionId string `gorm:"index:orders_block_transaction_id_index;column:block_transaction_id" json:"block_transaction_id"` Amount float64 `gorm:"column:amount" json:"amount"` + Currency string `gorm:"column:currency" json:"currency"` ActualAmount float64 `gorm:"column:actual_amount" json:"actual_amount"` + ReceiveAddress string `gorm:"column:receive_address" json:"receive_address"` Token string `gorm:"column:token" json:"token"` Status int `gorm:"column:status;default:1" json:"status"` NotifyUrl string `gorm:"column:notify_url" json:"notify_url"` diff --git a/src/model/mdb/transaction_lock_mdb.go b/src/model/mdb/transaction_lock_mdb.go index 26610ae..d94266e 100644 --- a/src/model/mdb/transaction_lock_mdb.go +++ b/src/model/mdb/transaction_lock_mdb.go @@ -4,8 +4,9 @@ import "time" type TransactionLock struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - Token string `gorm:"column:token;uniqueIndex:transaction_lock_token_amount_uindex,priority:1" json:"token"` - AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_token_amount_uindex,priority:2" json:"amount_scaled"` + Address string `gorm:"column:address;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:1" json:"address"` + Token string `gorm:"column:token;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:2" json:"token"` + AmountScaled int64 `gorm:"column:amount_scaled;uniqueIndex:transaction_lock_address_token_amount_uindex,priority:3" json:"amount_scaled"` AmountText string `gorm:"column:amount_text" json:"amount_text"` TradeId string `gorm:"column:trade_id;index:transaction_lock_trade_id_index" json:"trade_id"` ExpiresAt time.Time `gorm:"column:expires_at;index:transaction_lock_expires_at_index" json:"expires_at"` diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go index a723f99..84c84b4 100644 --- a/src/model/mdb/wallet_address_mdb.go +++ b/src/model/mdb/wallet_address_mdb.go @@ -6,8 +6,8 @@ const ( ) type WalletAddress struct { - Token string `gorm:"column:token;uniqueIndex:wallet_address_token_uindex" json:"token"` - Status int64 `gorm:"column:status;default:1" json:"status"` + Address string `gorm:"column:address;uniqueIndex:wallet_address_address_uindex" json:"address"` + Status int64 `gorm:"column:status;default:1" json:"status"` BaseModel } diff --git a/src/model/request/order_request.go b/src/model/request/order_request.go index 3fdf049..1123ef2 100644 --- a/src/model/request/order_request.go +++ b/src/model/request/order_request.go @@ -5,6 +5,8 @@ import "github.com/gookit/validate" // CreateTransactionRequest 创建交易请求 type CreateTransactionRequest struct { OrderId string `json:"order_id" validate:"required|maxLen:32"` + Currency string `json:"currency" validate:"required"` + Token string `json:"token" validate:"required"` Amount float64 `json:"amount" validate:"required|isFloat|gt:0.01"` NotifyUrl string `json:"notify_url" validate:"required"` Signature string `json:"signature" validate:"required"` @@ -14,6 +16,8 @@ type CreateTransactionRequest struct { func (r CreateTransactionRequest) Translates() map[string]string { return validate.MS{ "OrderId": "订单号", + "Currency": "货币", + "Token": "币种", "Amount": "支付金额", "NotifyUrl": "异步回调网址", "Signature": "签名", @@ -22,6 +26,8 @@ func (r CreateTransactionRequest) Translates() map[string]string { // OrderProcessingRequest 订单处理 type OrderProcessingRequest struct { + ReceiveAddress string + Currency string Token string Amount float64 TradeId string diff --git a/src/model/response/order_response.go b/src/model/response/order_response.go index 0bb18c9..a04902f 100644 --- a/src/model/response/order_response.go +++ b/src/model/response/order_response.go @@ -5,8 +5,10 @@ type CreateTransactionResponse struct { TradeId string `json:"trade_id"` // epusdt订单号 OrderId string `json:"order_id"` // 客户交易id Amount float64 `json:"amount"` // 订单金额,保留4位小数 + Currency string `json:"currency"` // 订单货币类型 CNY USD...... ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 - Token string `json:"token"` // 收款钱包地址 + ReceiveAddress string `json:"receive_address"` // 收款钱包地址 + Token string `json:"token"` // 所属币种 TRX USDT...... ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳 PaymentUrl string `json:"payment_url"` // 收银台地址 } @@ -17,7 +19,8 @@ type OrderNotifyResponse struct { OrderId string `json:"order_id"` // 客户交易id Amount float64 `json:"amount"` // 订单金额,保留4位小数 ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 - Token string `json:"token"` // 收款钱包地址 + ReceiveAddress string `json:"receive_address"` // 收款钱包地址 + Token string `json:"token"` // 所属币种 TRX USDT...... BlockTransactionId string `json:"block_transaction_id"` // 区块id Signature string `json:"signature"` // 签名 Status int `json:"status"` // 1:等待支付,2:支付成功,3:已过期 diff --git a/src/model/response/pay_response.go b/src/model/response/pay_response.go index 7d12e56..7aa8990 100644 --- a/src/model/response/pay_response.go +++ b/src/model/response/pay_response.go @@ -3,7 +3,8 @@ package response type CheckoutCounterResponse struct { TradeId string `json:"trade_id"` // epusdt订单号 ActualAmount float64 `json:"actual_amount"` // 订单实际需要支付的金额,保留4位小数 - Token string `json:"token"` // 收款钱包地址 + ReceiveAddress string `json:"receive_address"` // 收款钱包地址 + Token string `json:"token"` // 所属币种 TRX USDT...... ExpirationTime int64 `json:"expiration_time"` // 过期时间 时间戳 RedirectUrl string `json:"redirect_url"` } diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index ca25bbf..3b81773 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "math/rand" + "strings" "sync" "time" @@ -35,14 +36,20 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT gCreateTransactionLock.Lock() defer gCreateTransactionLock.Unlock() + token := strings.ToUpper(strings.TrimSpace(req.Token)) + currency := strings.ToUpper(strings.TrimSpace(req.Currency)) payAmount := math.MustParsePrecFloat64(req.Amount, 2) + rate := config.GetRateForCoin(strings.ToLower(token), strings.ToLower(currency)) + if rate <= 0 { + return nil, constant.RateAmountErr + } + decimalPayAmount := decimal.NewFromFloat(payAmount) - decimalRate := decimal.NewFromFloat(config.GetUsdtRate()) - decimalUsdt := decimalPayAmount.Div(decimalRate) + decimalTokenAmount := decimalPayAmount.Mul(decimal.NewFromFloat(rate)) if decimalPayAmount.Cmp(decimal.NewFromFloat(CnyMinimumPaymentAmount)) == -1 { return nil, constant.PayAmountErr } - if decimalUsdt.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 { + if decimalTokenAmount.Cmp(decimal.NewFromFloat(UsdtMinimumPaymentAmount)) == -1 { return nil, constant.PayAmountErr } @@ -62,35 +69,37 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT return nil, constant.NotAvailableWalletAddress } - tradeId := GenerateCode() - amount := math.MustParsePrecFloat64(decimalUsdt.InexactFloat64(), 2) - availableToken, availableAmount, err := ReserveAvailableWalletAndAmount(tradeId, amount, walletAddress) + tradeID := GenerateCode() + amount := math.MustParsePrecFloat64(decimalTokenAmount.InexactFloat64(), 2) + availableAddress, availableAmount, err := ReserveAvailableWalletAndAmount(tradeID, token, amount, walletAddress) if err != nil { return nil, err } - if availableToken == "" { + if availableAddress == "" { return nil, constant.NotAvailableAmountErr } tx := dao.Mdb.Begin() order := &mdb.Orders{ - TradeId: tradeId, - OrderId: req.OrderId, - Amount: req.Amount, - ActualAmount: availableAmount, - Token: availableToken, - Status: mdb.StatusWaitPay, - NotifyUrl: req.NotifyUrl, - RedirectUrl: req.RedirectUrl, + TradeId: tradeID, + OrderId: req.OrderId, + Amount: req.Amount, + Currency: currency, + ActualAmount: availableAmount, + ReceiveAddress: availableAddress, + Token: token, + Status: mdb.StatusWaitPay, + NotifyUrl: req.NotifyUrl, + RedirectUrl: req.RedirectUrl, } if err = data.CreateOrderWithTransaction(tx, order); err != nil { tx.Rollback() - _ = data.UnLockTransaction(availableToken, availableAmount) + _ = data.UnLockTransactionByTradeId(tradeID) return nil, err } if err = tx.Commit().Error; err != nil { tx.Rollback() - _ = data.UnLockTransaction(availableToken, availableAmount) + _ = data.UnLockTransactionByTradeId(tradeID) return nil, err } @@ -99,7 +108,9 @@ func CreateTransaction(req *request.CreateTransactionRequest) (*response.CreateT TradeId: order.TradeId, OrderId: order.OrderId, Amount: order.Amount, + Currency: order.Currency, ActualAmount: order.ActualAmount, + ReceiveAddress: order.ReceiveAddress, Token: order.Token, ExpirationTime: expirationTime, PaymentUrl: fmt.Sprintf("%s/pay/checkout-counter/%s", config.GetAppUri(), order.TradeId), @@ -137,22 +148,22 @@ func OrderProcessing(req *request.OrderProcessingRequest) error { return err } - if err = data.UnLockTransaction(req.Token, req.Amount); err != nil { + if err = data.UnLockTransaction(req.ReceiveAddress, req.Token, req.Amount); err != nil { log.Sugar.Warnf("[order] unlock transaction after pay success failed, trade_id=%s, err=%v", req.TradeId, err) } return nil } -// ReserveAvailableWalletAndAmount finds and locks a token+amount pair. -func ReserveAvailableWalletAndAmount(tradeId string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { - availableToken := "" +// ReserveAvailableWalletAndAmount finds and locks an address+token+amount pair. +func ReserveAvailableWalletAndAmount(tradeID string, token string, amount float64, walletAddress []mdb.WalletAddress) (string, float64, error) { + availableAddress := "" availableAmount := amount tryLockWalletFunc := func(targetAmount float64) (string, error) { for _, address := range walletAddress { - err := data.LockTransaction(address.Token, tradeId, targetAmount, config.GetOrderExpirationTimeDuration()) + err := data.LockTransaction(address.Address, token, tradeID, targetAmount, config.GetOrderExpirationTimeDuration()) if err == nil { - return address.Token, nil + return address.Address, nil } if errors.Is(err, data.ErrTransactionLocked) { continue @@ -163,20 +174,20 @@ func ReserveAvailableWalletAndAmount(tradeId string, amount float64, walletAddre } for i := 0; i < IncrementalMaximumNumber; i++ { - token, err := tryLockWalletFunc(availableAmount) + address, err := tryLockWalletFunc(availableAmount) if err != nil { return "", 0, err } - if token == "" { + if address == "" { decimalOldAmount := decimal.NewFromFloat(availableAmount) decimalIncr := decimal.NewFromFloat(UsdtAmountPerIncrement) availableAmount = decimalOldAmount.Add(decimalIncr).InexactFloat64() continue } - availableToken = token + availableAddress = address break } - return availableToken, availableAmount, nil + return availableAddress, availableAmount, nil } // GenerateCode creates a unique trade id. diff --git a/src/model/service/order_service_test.go b/src/model/service/order_service_test.go index 8c354d1..3aad2e0 100644 --- a/src/model/service/order_service_test.go +++ b/src/model/service/order_service_test.go @@ -13,6 +13,16 @@ import ( "github.com/assimon/luuu/util/constant" ) +func newCreateTransactionRequest(orderID string, amount float64) *request.CreateTransactionRequest { + return &request.CreateTransactionRequest{ + OrderId: orderID, + Currency: "CNY", + Token: "USDT", + Amount: amount, + NotifyUrl: "https://merchant.example/callback", + } +} + func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) { cleanup := testutil.SetupTestDatabases(t) defer cleanup() @@ -21,22 +31,11 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) { t.Fatalf("add wallet: %v", err) } - req1 := &request.CreateTransactionRequest{ - OrderId: "order_1", - Amount: 1, - NotifyUrl: "https://merchant.example/callback", - } - req2 := &request.CreateTransactionRequest{ - OrderId: "order_2", - Amount: 1, - NotifyUrl: "https://merchant.example/callback", - } - - resp1, err := CreateTransaction(req1) + resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1)) if err != nil { t.Fatalf("create first transaction: %v", err) } - resp2, err := CreateTransaction(req2) + resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 1)) if err != nil { t.Fatalf("create second transaction: %v", err) } @@ -47,11 +46,14 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) { if got := fmt.Sprintf("%.2f", resp2.ActualAmount); got != "1.01" { t.Fatalf("second actual amount = %s, want 1.01", got) } - if resp1.Token != "wallet_1" || resp2.Token != "wallet_1" { - t.Fatalf("unexpected wallet tokens: %s, %s", resp1.Token, resp2.Token) + if resp1.ReceiveAddress != "wallet_1" || resp2.ReceiveAddress != "wallet_1" { + t.Fatalf("unexpected receive addresses: %s, %s", resp1.ReceiveAddress, resp2.ReceiveAddress) + } + if resp1.Token != "USDT" || resp2.Token != "USDT" { + t.Fatalf("unexpected tokens: %s, %s", resp1.Token, resp2.Token) } - tradeID1, err := data.GetTradeIdByWalletAddressAndAmount(resp1.Token, resp1.ActualAmount) + tradeID1, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp1.ReceiveAddress, resp1.Token, resp1.ActualAmount) if err != nil { t.Fatalf("get first runtime lock: %v", err) } @@ -59,7 +61,7 @@ func TestCreateTransactionAssignsIncrementedAmountsAndLocks(t *testing.T) { t.Fatalf("first runtime lock = %s, want %s", tradeID1, resp1.TradeId) } - tradeID2, err := data.GetTradeIdByWalletAddressAndAmount(resp2.Token, resp2.ActualAmount) + tradeID2, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp2.ReceiveAddress, resp2.Token, resp2.ActualAmount) if err != nil { t.Fatalf("get second runtime lock: %v", err) } @@ -76,16 +78,13 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) { t.Fatalf("add wallet: %v", err) } - resp, err := CreateTransaction(&request.CreateTransactionRequest{ - OrderId: "order_1", - Amount: 1, - NotifyUrl: "https://merchant.example/callback", - }) + resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1)) if err != nil { t.Fatalf("create transaction: %v", err) } err = OrderProcessing(&request.OrderProcessingRequest{ + ReceiveAddress: resp.ReceiveAddress, Token: resp.Token, TradeId: resp.TradeId, Amount: resp.ActualAmount, @@ -109,7 +108,7 @@ func TestOrderProcessingMarksPaidAndReleasesLock(t *testing.T) { t.Fatalf("block transaction id = %s, want block_1", order.BlockTransactionId) } - tradeID, err := data.GetTradeIdByWalletAddressAndAmount(resp.Token, resp.ActualAmount) + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(resp.ReceiveAddress, resp.Token, resp.ActualAmount) if err != nil { t.Fatalf("get runtime lock after processing: %v", err) } @@ -126,16 +125,13 @@ func TestOrderProcessingRejectsDuplicateBlockForSameOrder(t *testing.T) { t.Fatalf("add wallet: %v", err) } - resp, err := CreateTransaction(&request.CreateTransactionRequest{ - OrderId: "order_1", - Amount: 1, - NotifyUrl: "https://merchant.example/callback", - }) + resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1)) if err != nil { t.Fatalf("create transaction: %v", err) } req := &request.OrderProcessingRequest{ + ReceiveAddress: resp.ReceiveAddress, Token: resp.Token, TradeId: resp.TradeId, Amount: resp.ActualAmount, @@ -170,11 +166,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) { t.Fatalf("add wallet: %v", err) } - resp, err := CreateTransaction(&request.CreateTransactionRequest{ - OrderId: "order_1", - Amount: 1, - NotifyUrl: "https://merchant.example/callback", - }) + resp, err := CreateTransaction(newCreateTransactionRequest("order_1", 1)) if err != nil { t.Fatalf("create transaction: %v", err) } @@ -186,6 +178,7 @@ func TestOrderProcessingDoesNotReviveExpiredOrder(t *testing.T) { } err = OrderProcessing(&request.OrderProcessingRequest{ + ReceiveAddress: resp.ReceiveAddress, Token: resp.Token, TradeId: resp.TradeId, Amount: resp.ActualAmount, @@ -218,19 +211,11 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) { t.Fatalf("add wallet: %v", err) } - resp1, err := CreateTransaction(&request.CreateTransactionRequest{ - OrderId: "order_1", - Amount: 1, - NotifyUrl: "https://merchant.example/callback", - }) + resp1, err := CreateTransaction(newCreateTransactionRequest("order_1", 1)) if err != nil { t.Fatalf("create first transaction: %v", err) } - resp2, err := CreateTransaction(&request.CreateTransactionRequest{ - OrderId: "order_2", - Amount: 2, - NotifyUrl: "https://merchant.example/callback", - }) + resp2, err := CreateTransaction(newCreateTransactionRequest("order_2", 2)) if err != nil { t.Fatalf("create second transaction: %v", err) } @@ -239,24 +224,26 @@ func TestOrderProcessingOnlyOneOrderClaimsABlockTransaction(t *testing.T) { errs := make(chan error, 2) var wg sync.WaitGroup for _, tc := range []struct { + address string token string tradeID string amount float64 }{ - {token: resp1.Token, tradeID: resp1.TradeId, amount: resp1.ActualAmount}, - {token: resp2.Token, tradeID: resp2.TradeId, amount: resp2.ActualAmount}, + {address: resp1.ReceiveAddress, token: resp1.Token, tradeID: resp1.TradeId, amount: resp1.ActualAmount}, + {address: resp2.ReceiveAddress, token: resp2.Token, tradeID: resp2.TradeId, amount: resp2.ActualAmount}, } { wg.Add(1) - go func(token, tradeID string, amount float64) { + go func(address, token, tradeID string, amount float64) { defer wg.Done() <-start errs <- OrderProcessing(&request.OrderProcessingRequest{ + ReceiveAddress: address, Token: token, TradeId: tradeID, Amount: amount, BlockTransactionId: "shared_block", }) - }(tc.token, tc.tradeID, tc.amount) + }(tc.address, tc.token, tc.tradeID, tc.amount) } close(start) diff --git a/src/model/service/pay_service.go b/src/model/service/pay_service.go index 902220f..f10cc39 100644 --- a/src/model/service/pay_service.go +++ b/src/model/service/pay_service.go @@ -9,18 +9,20 @@ import ( "github.com/assimon/luuu/model/response" ) -// GetCheckoutCounterByTradeId 获取收银台详情,通过订单 +// GetCheckoutCounterByTradeId returns checkout info for a pending order. func GetCheckoutCounterByTradeId(tradeId string) (*response.CheckoutCounterResponse, error) { orderInfo, err := data.GetOrderInfoByTradeId(tradeId) if err != nil { return nil, err } if orderInfo.ID <= 0 || orderInfo.Status != mdb.StatusWaitPay { - return nil, errors.New("不存在待支付订单或已过期!") + return nil, errors.New("pending order does not exist or has expired") } + resp := &response.CheckoutCounterResponse{ TradeId: orderInfo.TradeId, ActualAmount: orderInfo.ActualAmount, + ReceiveAddress: orderInfo.ReceiveAddress, Token: orderInfo.Token, ExpirationTime: orderInfo.CreatedAt.AddMinutes(config.GetOrderExpirationTime()).TimestampMilli(), RedirectUrl: orderInfo.RedirectUrl, diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go index 218ab82..587e70a 100644 --- a/src/model/service/task_service.go +++ b/src/model/service/task_service.go @@ -1,67 +1,32 @@ package service import ( + "encoding/hex" "errors" "fmt" "net/http" + "strings" "sync" + tron "github.com/assimon/luuu/crypto" + "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/telegram" "github.com/assimon/luuu/util/constant" "github.com/assimon/luuu/util/http_client" - "github.com/assimon/luuu/util/json" "github.com/assimon/luuu/util/log" + "github.com/assimon/luuu/util/math" "github.com/dromara/carbon/v2" "github.com/gookit/goutil/stdutil" "github.com/shopspring/decimal" + "github.com/tidwall/gjson" ) -const UsdtTrc20ApiUri = "https://apilist.tronscanapi.com/api/transfer/trc20" +const TRC20_USDT_ID = "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t" -type UsdtTrc20Resp struct { - PageSize int `json:"page_size"` - Code int `json:"code"` - Data []Data `json:"data"` -} - -type TokenInfo struct { - TokenID string `json:"tokenId"` - TokenAbbr string `json:"tokenAbbr"` - TokenName string `json:"tokenName"` - TokenDecimal int `json:"tokenDecimal"` - TokenCanShow int `json:"tokenCanShow"` - TokenType string `json:"tokenType"` - TokenLogo string `json:"tokenLogo"` - TokenLevel string `json:"tokenLevel"` - IssuerAddr string `json:"issuerAddr"` - Vip bool `json:"vip"` -} - -type Data struct { - Amount string `json:"amount"` - ApprovalAmount string `json:"approval_amount"` - BlockTimestamp int64 `json:"block_timestamp"` - Block int `json:"block"` - From string `json:"from"` - To string `json:"to"` - Hash string `json:"hash"` - Confirmed int `json:"confirmed"` - ContractType string `json:"contract_type"` - ContracTType int `json:"contractType"` - Revert int `json:"revert"` - ContractRet string `json:"contract_ret"` - EventType string `json:"event_type"` - IssueAddress string `json:"issue_address"` - Decimals int `json:"decimals"` - TokenName string `json:"token_name"` - ID string `json:"id"` - Direction int `json:"direction"` -} - -// Trc20CallBack polls transfers for one wallet and matches them to active orders. -func Trc20CallBack(token string, wg *sync.WaitGroup) { +func Trc20CallBack(address string, wg *sync.WaitGroup) { defer wg.Done() defer func() { if err := recover(); err != nil { @@ -69,88 +34,218 @@ func Trc20CallBack(token string, wg *sync.WaitGroup) { } }() + var innerWg sync.WaitGroup + innerWg.Add(2) + go checkTrxTransfers(address, &innerWg) + go checkTrc20Transfers(address, &innerWg) + innerWg.Wait() +} + +func checkTrxTransfers(address string, wg *sync.WaitGroup) { + defer wg.Done() + defer func() { + if err := recover(); err != nil { + log.Sugar.Errorf("[TRX][%s] panic recovered: %v", address, err) + } + }() + client := http_client.GetHttpClient() startTime := carbon.Now().AddHours(-24).TimestampMilli() endTime := carbon.Now().TimestampMilli() + url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions", address) + resp, err := client.R().SetQueryParams(map[string]string{ - "sort": "-timestamp", - "limit": "50", - "start": "0", - "direction": "2", - "db_version": "1", - "trc20Id": "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", - "address": token, - "start_timestamp": stdutil.ToString(startTime), - "end_timestamp": stdutil.ToString(endTime), - }).Get(UsdtTrc20ApiUri) + "order_by": "block_timestamp,desc", + "limit": "100", + "only_to": "true", + "min_timestamp": stdutil.ToString(startTime), + "max_timestamp": stdutil.ToString(endTime), + }).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url) if err != nil { panic(err) } if resp.StatusCode() != http.StatusOK { - panic(err) + panic(fmt.Sprintf("TRX API returned status %d", resp.StatusCode())) } - var trc20Resp UsdtTrc20Resp - if err = json.Cjson.Unmarshal(resp.Body(), &trc20Resp); err != nil { - panic(err) - } - if trc20Resp.PageSize <= 0 { - return + success := gjson.GetBytes(resp.Body(), "success").Bool() + if !success { + panic("TRX API response indicates failure") } - for _, transfer := range trc20Resp.Data { - if transfer.To != token || transfer.ContractRet != "SUCCESS" { + for i, transfer := range gjson.GetBytes(resp.Body(), "data").Array() { + if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" { + continue + } + if transfer.Get("ret.0.contractRet").String() != "SUCCESS" { continue } - decimalQuant, err := decimal.NewFromString(transfer.Amount) + toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String() + toBytes, err := hex.DecodeString(toAddressHex) if err != nil { - panic(err) + log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err) + continue } - decimalDivisor := decimal.NewFromFloat(1000000) - amount := decimalQuant.Div(decimalDivisor).InexactFloat64() - tradeId, err := data.GetTradeIdByWalletAddressAndAmount(token, amount) - if err != nil { - panic(err) - } - if tradeId == "" { + if tron.EncodeCheck(toBytes) != address { continue } - order, err := data.GetOrderInfoByTradeId(tradeId) + rawAmount := transfer.Get("raw_data.contract.0.parameter.value.amount").String() + decimalQuant, err := decimal.NewFromString(rawAmount) + if err != nil { + log.Sugar.Errorf("[TRX][%s] parse amount failed on tx #%d: %v", address, i, err) + continue + } + amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1000000)).InexactFloat64(), 2) + if amount <= 0 { + continue + } + + txID := transfer.Get("txID").String() + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "TRX", amount) if err != nil { panic(err) } + if tradeID == "" { + continue + } + + order, err := data.GetOrderInfoByTradeId(tradeID) + if err != nil { + panic(err) + } + blockTimestamp := transfer.Get("block_timestamp").Int() createTime := order.CreatedAt.TimestampMilli() - if transfer.BlockTimestamp < createTime { - panic("orders cannot actually be matched") + if blockTimestamp < createTime { + log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime) + continue } req := &request.OrderProcessingRequest{ - Token: token, - TradeId: tradeId, + ReceiveAddress: address, + Token: "TRX", + TradeId: tradeID, Amount: amount, - BlockTransactionId: transfer.Hash, + BlockTransactionId: txID, } - if err = OrderProcessing(req); err != nil { + err = OrderProcessing(req) + if err != nil { if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { - log.Sugar.Infof("[task] skip already resolved transfer, trade_id=%s, block_transaction_id=%s, err=%v", tradeId, transfer.Hash, err) + log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err) continue } panic(err) } - msgTpl := ` -馃摙馃摙鏈夋柊鐨勪氦鏄撴敮浠樻垚鍔燂紒 -
浜ゆ槗鍙凤細%s-
璁㈠崟鍙凤細%s-
璇锋眰鏀粯閲戦锛?f cny-
瀹為檯鏀粯閲戦锛?f usdt-
閽卞寘鍦板潃锛?s-
璁㈠崟鍒涘缓鏃堕棿锛?s-
鏀粯鎴愬姛鏃堕棿锛?s-` - msg := fmt.Sprintf(msgTpl, order.TradeId, order.OrderId, order.Amount, order.ActualAmount, order.Token, order.CreatedAt.ToDateTimeString(), carbon.Now().ToDateTimeString()) - telegram.SendToBot(msg) + sendPaymentNotification(order) } } + +func checkTrc20Transfers(address string, wg *sync.WaitGroup) { + defer wg.Done() + defer func() { + if err := recover(); err != nil { + log.Sugar.Errorf("[TRC20][%s] panic recovered: %v", address, err) + } + }() + + client := http_client.GetHttpClient() + startTime := carbon.Now().AddHours(-24).TimestampMilli() + endTime := carbon.Now().TimestampMilli() + url := fmt.Sprintf("https://api.trongrid.io/v1/accounts/%s/transactions/trc20", address) + + resp, err := client.R().SetQueryParams(map[string]string{ + "order_by": "block_timestamp,desc", + "limit": "100", + "only_to": "true", + "min_timestamp": stdutil.ToString(startTime), + "max_timestamp": stdutil.ToString(endTime), + }).SetHeader("TRON-PRO-API-KEY", config.TRON_GRID_API_KEY).Get(url) + if err != nil { + panic(err) + } + if resp.StatusCode() != http.StatusOK { + panic(fmt.Sprintf("TRC20 API returned status %d", resp.StatusCode())) + } + + success := gjson.GetBytes(resp.Body(), "success").Bool() + if !success { + panic("TRC20 API response indicates failure") + } + + for i, transfer := range gjson.GetBytes(resp.Body(), "data").Array() { + if transfer.Get("token_info.address").String() != TRC20_USDT_ID { + continue + } + if transfer.Get("to").String() != address { + continue + } + + valueStr := transfer.Get("value").String() + decimalQuant, err := decimal.NewFromString(valueStr) + if err != nil { + log.Sugar.Errorf("[TRC20][%s] parse value failed on tx #%d: %v", address, i, err) + continue + } + tokenDecimals := transfer.Get("token_info.decimals").Int() + amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2) + if amount <= 0 { + continue + } + + txID := transfer.Get("transaction_id").String() + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(address, "USDT", amount) + if err != nil { + panic(err) + } + if tradeID == "" { + continue + } + + order, err := data.GetOrderInfoByTradeId(tradeID) + if err != nil { + panic(err) + } + blockTimestamp := transfer.Get("block_timestamp").Int() + createTime := order.CreatedAt.TimestampMilli() + if blockTimestamp < createTime { + log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", address, txID, blockTimestamp, createTime) + continue + } + + req := &request.OrderProcessingRequest{ + ReceiveAddress: address, + Token: "USDT", + TradeId: tradeID, + Amount: amount, + BlockTransactionId: txID, + } + err = OrderProcessing(req) + if err != nil { + if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { + log.Sugar.Infof("[TRC20][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err) + continue + } + panic(err) + } + + sendPaymentNotification(order) + } +} + +func sendPaymentNotification(order *mdb.Orders) { + msg := fmt.Sprintf( + "Payment received\nTrade ID: %s\nOrder ID: %s\nOrder Amount: %.2f %s\nReceived Amount: %.2f %s\nAddress: %s\nCreated At: %s\nPaid At: %s", + order.TradeId, + order.OrderId, + order.Amount, + strings.ToUpper(order.Currency), + order.ActualAmount, + strings.ToUpper(order.Token), + order.ReceiveAddress, + order.CreatedAt.ToDateTimeString(), + carbon.Now().ToDateTimeString(), + ) + telegram.SendToBot(msg) +} diff --git a/src/mq/worker.go b/src/mq/worker.go index ff9fb04..4f64af1 100644 --- a/src/mq/worker.go +++ b/src/mq/worker.go @@ -75,7 +75,7 @@ func processExpiredOrders() { if !expired { continue } - if err = data.UnLockTransaction(order.Token, order.ActualAmount); err != nil { + if err = data.UnLockTransaction(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) } } @@ -147,6 +147,7 @@ func sendOrderCallback(order *mdb.Orders) error { OrderId: order.OrderId, Amount: order.Amount, ActualAmount: order.ActualAmount, + ReceiveAddress: order.ReceiveAddress, Token: order.Token, BlockTransactionId: order.BlockTransactionId, Status: mdb.StatusPaySuccess, diff --git a/src/mq/worker_test.go b/src/mq/worker_test.go index 6869e70..b838a40 100644 --- a/src/mq/worker_test.go +++ b/src/mq/worker_test.go @@ -20,13 +20,15 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) defer cleanup() order := &mdb.Orders{ - TradeId: "trade_expired", - OrderId: "order_expired", - Amount: 1, - ActualAmount: 1, - Token: "wallet_1", - Status: mdb.StatusWaitPay, - NotifyUrl: "https://merchant.example/callback", + TradeId: "trade_expired", + OrderId: "order_expired", + Amount: 1, + Currency: "CNY", + ActualAmount: 1, + ReceiveAddress: "wallet_1", + Token: "USDT", + Status: mdb.StatusWaitPay, + NotifyUrl: "https://merchant.example/callback", } if err := dao.Mdb.Create(order).Error; err != nil { t.Fatalf("create expired order: %v", err) @@ -34,23 +36,25 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) if err := dao.Mdb.Model(order).UpdateColumn("created_at", time.Now().Add(-20*time.Minute)).Error; err != nil { t.Fatalf("age expired order: %v", err) } - if err := data.LockTransaction(order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil { + if err := data.LockTransaction(order.ReceiveAddress, order.Token, order.TradeId, order.ActualAmount, time.Hour); err != nil { t.Fatalf("lock expired order: %v", err) } recentOrder := &mdb.Orders{ - TradeId: "trade_recent", - OrderId: "order_recent", - Amount: 1, - ActualAmount: 1.01, - Token: "wallet_1", - Status: mdb.StatusWaitPay, - NotifyUrl: "https://merchant.example/callback", + TradeId: "trade_recent", + OrderId: "order_recent", + Amount: 1, + Currency: "CNY", + ActualAmount: 1.01, + ReceiveAddress: "wallet_1", + Token: "USDT", + Status: mdb.StatusWaitPay, + NotifyUrl: "https://merchant.example/callback", } if err := dao.Mdb.Create(recentOrder).Error; err != nil { t.Fatalf("create recent order: %v", err) } - if err := data.LockTransaction(recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil { + if err := data.LockTransaction(recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.TradeId, recentOrder.ActualAmount, time.Hour); err != nil { t.Fatalf("lock recent order: %v", err) } @@ -63,7 +67,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) if expired.Status != mdb.StatusExpired { t.Fatalf("expired order status = %d, want %d", expired.Status, mdb.StatusExpired) } - lockTradeID, err := data.GetTradeIdByWalletAddressAndAmount(order.Token, order.ActualAmount) + lockTradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(order.ReceiveAddress, order.Token, order.ActualAmount) if err != nil { t.Fatalf("expired order lock lookup: %v", err) } @@ -78,7 +82,7 @@ func TestProcessExpiredOrdersExpiresWaitingOrdersAndReleasesLocks(t *testing.T) if recent.Status != mdb.StatusWaitPay { t.Fatalf("recent order status = %d, want %d", recent.Status, mdb.StatusWaitPay) } - lockTradeID, err = data.GetTradeIdByWalletAddressAndAmount(recentOrder.Token, recentOrder.ActualAmount) + lockTradeID, err = data.GetTradeIdByWalletAddressAndAmountAndToken(recentOrder.ReceiveAddress, recentOrder.Token, recentOrder.ActualAmount) if err != nil { t.Fatalf("recent order lock lookup: %v", err) } @@ -95,8 +99,10 @@ func TestProcessExpiredOrdersKeepsPaidOrdersPaid(t *testing.T) { TradeId: "trade_paid", OrderId: "order_paid", Amount: 1, + Currency: "CNY", ActualAmount: 1, - Token: "wallet_1", + ReceiveAddress: "wallet_1", + Token: "USDT", Status: mdb.StatusPaySuccess, NotifyUrl: "https://merchant.example/callback", BlockTransactionId: "block_paid", @@ -141,8 +147,10 @@ func TestDispatchPendingCallbacksHonorsBackoffAndPersistsSuccess(t *testing.T) { TradeId: "trade_callback", OrderId: "order_callback", Amount: 1, + Currency: "CNY", ActualAmount: 1, - Token: "wallet_1", + ReceiveAddress: "wallet_1", + Token: "USDT", Status: mdb.StatusPaySuccess, NotifyUrl: server.URL, BlockTransactionId: "block_callback", diff --git a/src/task/listen.go b/src/task/listen.go index 46991eb..3eb96ca 100644 --- a/src/task/listen.go +++ b/src/task/listen.go @@ -1,12 +1,20 @@ package task -import "github.com/robfig/cron/v3" +import ( + "github.com/assimon/luuu/util/log" + "github.com/robfig/cron/v3" +) func Start() { + log.Sugar.Info("[task] Starting task scheduler...") c := cron.New() - // 汇率监听 - c.AddJob("@every 60s", UsdtRateJob{}) // trc20钱包监听 - c.AddJob("@every 5s", ListenTrc20Job{}) + _, err := c.AddJob("@every 5s", ListenTrc20Job{}) + if err != nil { + log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err) + return + } + log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)") c.Start() + log.Sugar.Info("[task] Task scheduler started") } diff --git a/src/task/listen_trc20_job.go b/src/task/listen_trc20_job.go index c4a180e..f35cc86 100644 --- a/src/task/listen_trc20_job.go +++ b/src/task/listen_trc20_job.go @@ -1,10 +1,11 @@ package task import ( + "sync" + "github.com/assimon/luuu/model/data" "github.com/assimon/luuu/model/service" "github.com/assimon/luuu/util/log" - "sync" ) type ListenTrc20Job struct { @@ -15,18 +16,24 @@ var gListenTrc20JobLock sync.Mutex func (r ListenTrc20Job) Run() { gListenTrc20JobLock.Lock() defer gListenTrc20JobLock.Unlock() + log.Sugar.Debug("[ListenTrc20Job] Job triggered") walletAddress, err := data.GetAvailableWalletAddress() if err != nil { - log.Sugar.Error(err) + log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err) return } if len(walletAddress) <= 0 { + log.Sugar.Debug("[ListenTrc20Job] No available wallet addresses") return } + log.Sugar.Infof("[ListenTrc20Job] Found %d wallet addresses to monitor", len(walletAddress)) var wg sync.WaitGroup for _, address := range walletAddress { + log.Sugar.Infof("[ListenTrc20Job] Listening to address: %s", address.Address) + wg.Add(1) - go service.Trc20CallBack(address.Token, &wg) + go service.Trc20CallBack(address.Address, &wg) } wg.Wait() + log.Sugar.Debug("[ListenTrc20Job] Job completed") } diff --git a/src/task/usdt_rate_job.go b/src/task/usdt_rate_job.go deleted file mode 100644 index 90a8891..0000000 --- a/src/task/usdt_rate_job.go +++ /dev/null @@ -1,62 +0,0 @@ -package task - -import ( - "github.com/assimon/luuu/config" - "github.com/assimon/luuu/util/http_client" - "github.com/assimon/luuu/util/json" - "github.com/assimon/luuu/util/log" - "github.com/assimon/luuu/util/math" - "time" -) - -const UsdtRateApiUri = "https://api.coinmarketcap.com/data-api/v3/cryptocurrency/detail/chart" - -type UsdtRateJob struct { -} - -type UsdtRateResp struct { - Data Data `json:"data"` - Status Status `json:"status"` -} - -type Status struct { - Timestamp time.Time `json:"timestamp"` - ErrorCode string `json:"error_code"` - ErrorMessage string `json:"error_message"` - Elapsed string `json:"elapsed"` - CreditCount int `json:"credit_count"` -} - -type Data struct { - Points map[string]Points `json:"points"` -} - -type Points struct { - V []float64 `json:"v"` - C []float64 `json:"c"` -} - -func (r UsdtRateJob) Run() { - client := http_client.GetHttpClient() - resp, err := client.R().SetQueryString("id=825&range=1H&convertId=2787").SetHeader("Accept", "application/json").Get(UsdtRateApiUri) - if err != nil { - log.Sugar.Error("usdt rate get err:", err.Error()) - return - } - var usdtResp UsdtRateResp - err = json.Cjson.Unmarshal(resp.Body(), &usdtResp) - if err != nil { - log.Sugar.Error("Unmarshal usdt resp err:", err.Error()) - return - } - if usdtResp.Status.ErrorCode != "0" { - log.Sugar.Error("usdt resp err:", usdtResp.Status.ErrorMessage) - return - } - for _, points := range usdtResp.Data.Points { - if len(points.C) > 0 && points.C[0] > 0 { - config.UsdtRate = math.MustParsePrecFloat64(points.C[0], 2) - return - } - } -} diff --git a/src/telegram/handle.go b/src/telegram/handle.go index 8b4a3ef..3b5f008 100644 --- a/src/telegram/handle.go +++ b/src/telegram/handle.go @@ -45,8 +45,8 @@ func WalletList(c tb.Context) error { } var temp []tb.InlineButton btnInfo := tb.InlineButton{ - Unique: wallet.Token, - Text: fmt.Sprintf("%s[%s]", wallet.Token, status), + Unique: wallet.Address, + Text: fmt.Sprintf("%s[%s]", wallet.Address, status), Data: strutil.MustString(wallet.ID), } bots.Handle(&btnInfo, WalletInfo) @@ -93,7 +93,7 @@ func WalletInfo(c tb.Context) error { bots.Handle(&disableBtn, DisableWallet) bots.Handle(&delBtn, DelWallet) bots.Handle(&backBtn, WalletList) - return c.EditOrReply(tokenInfo.Token, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{ + return c.EditOrReply(tokenInfo.Address, &tb.ReplyMarkup{InlineKeyboard: [][]tb.InlineButton{ { enableBtn, disableBtn, diff --git a/src/telegram/telegram.go b/src/telegram/telegram.go index b29bf63..7410a55 100644 --- a/src/telegram/telegram.go +++ b/src/telegram/telegram.go @@ -46,14 +46,21 @@ func RegisterHandle() { // SendToBot 主动发送消息机器人消息 func SendToBot(msg string) { go func() { + if bots == nil { + log.Sugar.Error("[Telegram] bots实例为nil,无法发送消息") + return + } user := tb.User{ ID: config.TgManage, } + log.Sugar.Infof("[Telegram] 正在发送消息给用户ID=%d", config.TgManage) _, err := bots.Send(&user, msg, &tb.SendOptions{ ParseMode: tb.ModeHTML, }) if err != nil { - log.Sugar.Error(err) + log.Sugar.Errorf("[Telegram] 发送消息失败: userID=%d, err=%v", config.TgManage, err) + } else { + log.Sugar.Infof("[Telegram] 发送消息成功: userID=%d", config.TgManage) } }() }