From 0a19854602ed9748c9282ed82e49f0ef75929f4d Mon Sep 17 00:00:00 2001
From: Ginta <775650117@qq.com>
Date: Sun, 26 Apr 2026 23:32:43 +0800
Subject: [PATCH 1/8] new trc (#54)
* new trct
---
src/model/service/task_service.go | 439 +++++++++---------------------
src/task/listen.go | 19 +-
src/task/listen_trc20_block.go | 378 +++++++++++++++++++++++++
src/task/listen_trc20_job.go | 43 ---
4 files changed, 511 insertions(+), 368 deletions(-)
create mode 100644 src/task/listen_trc20_block.go
delete mode 100644 src/task/listen_trc20_job.go
diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go
index 8e6c0d2..6ff16be 100644
--- a/src/model/service/task_service.go
+++ b/src/model/service/task_service.go
@@ -1,32 +1,23 @@
package service
import (
- "encoding/hex"
"errors"
"fmt"
"math/big"
- "net/http"
"strings"
- "sync"
- tron "github.com/GMWalletApp/epusdt/crypto"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/notify"
"github.com/GMWalletApp/epusdt/util/constant"
- "github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common"
- "github.com/gookit/goutil/stdutil"
"github.com/shopspring/decimal"
- "github.com/tidwall/gjson"
)
-// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
-// It reads the first healthy (or any enabled) row from the rpc_nodes table.
func resolveTronNode() (string, string, error) {
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
@@ -42,296 +33,128 @@ func resolveTronNode() (string, string, error) {
return rpcURL, node.ApiKey, nil
}
-func Trc20CallBack(address string, wg *sync.WaitGroup) {
- defer wg.Done()
+func ResolveTronNode() (string, string, error) {
+ return resolveTronNode()
+}
+
+func TryProcessTronTRC20Transfer(toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
- log.Sugar.Error(err)
+ log.Sugar.Errorf("[TRC20][%s] TryProcessTronTRC20Transfer panic: %v", toAddr, err)
}
}()
- var innerWg sync.WaitGroup
- innerWg.Add(2)
- go checkTrxTransfers(address, &innerWg)
- go checkTrc20Transfers(address, &innerWg)
- innerWg.Wait()
+ addr := strings.TrimSpace(toAddr)
+ if addr == "" || rawValue == nil || rawValue.Sign() <= 0 {
+ return
+ }
+
+ decimalQuant := decimal.NewFromBigInt(rawValue, 0)
+ amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
+ if amount <= 0 {
+ return
+ }
+
+ tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "USDT", amount)
+ if err != nil {
+ log.Sugar.Warnf("[TRC20][%s] lock lookup: %v", addr, err)
+ return
+ }
+ if tradeID == "" {
+ log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
+ return
+ }
+
+ order, err := data.GetOrderInfoByTradeId(tradeID)
+ if err != nil {
+ log.Sugar.Warnf("[TRC20][%s] load order: %v", addr, err)
+ return
+ }
+ if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
+ log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
+ return
+ }
+
+ req := &request.OrderProcessingRequest{
+ ReceiveAddress: addr,
+ Token: "USDT",
+ Network: mdb.NetworkTron,
+ TradeId: tradeID,
+ Amount: amount,
+ BlockTransactionId: txHash,
+ }
+ 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", addr, tradeID, txHash, err)
+ return
+ }
+ log.Sugar.Errorf("[TRC20][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
+ return
+ }
+
+ sendPaymentNotification(order)
+ log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
}
-func checkTrxTransfers(address string, wg *sync.WaitGroup) {
- defer wg.Done()
+func TryProcessTronTRXTransfer(toAddr string, rawSun int64, txHash string, blockTsMs int64) {
+ defer func() {
+ if err := recover(); err != nil {
+ log.Sugar.Errorf("[TRX][%s] TryProcessTronTRXTransfer panic: %v", toAddr, err)
+ }
+ }()
- // Native TRX is gated by a chain_tokens row with empty
- // contract_address and symbol=TRX. Admin can disable this row to
- // stop scanning native transfers without touching the chain toggle.
- trxCfg, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, "TRX")
+ addr := strings.TrimSpace(toAddr)
+ if addr == "" || rawSun <= 0 {
+ return
+ }
+
+ decimalQuant := decimal.NewFromInt(rawSun)
+ amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
+ if amount <= 0 {
+ return
+ }
+
+ tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "TRX", amount)
if err != nil {
- log.Sugar.Errorf("[TRX][%s] load chain_tokens err=%v", address, err)
+ log.Sugar.Warnf("[TRX][%s] lock lookup: %v", addr, err)
return
}
- if trxCfg == nil || trxCfg.ID == 0 {
- log.Sugar.Debugf("[TRX][%s] native TRX disabled in chain_tokens, skipping", address)
+ if tradeID == "" {
+ log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
return
}
- trxDecimals := trxCfg.Decimals
- if trxDecimals <= 0 {
- trxDecimals = 6
- }
- client := http_client.GetHttpClient()
- startTime := carbon.Now().AddHours(-24).TimestampMilli()
- endTime := carbon.Now().TimestampMilli()
- tronBaseURL, tronAPIKey, err := resolveTronNode()
+ order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
- log.Sugar.Errorf("[TRX][%s] resolve rpc_nodes err=%v", address, err)
+ log.Sugar.Warnf("[TRX][%s] load order: %v", addr, err)
+ return
+ }
+ if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
+ log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return
}
- url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, 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", tronAPIKey).Get(url)
+ req := &request.OrderProcessingRequest{
+ ReceiveAddress: addr,
+ Token: "TRX",
+ Network: mdb.NetworkTron,
+ TradeId: tradeID,
+ Amount: amount,
+ BlockTransactionId: txHash,
+ }
+ err = OrderProcessing(req)
if err != nil {
- log.Sugar.Errorf("[TRX][%s] HTTP request failed: %v", address, err)
- return
- }
- if resp.StatusCode() != http.StatusOK {
- log.Sugar.Errorf("[TRX][%s] API returned status %d", address, resp.StatusCode())
+ if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
+ log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
+ return
+ }
+ log.Sugar.Errorf("[TRX][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
return
}
- success := gjson.GetBytes(resp.Body(), "success").Bool()
- if !success {
- log.Sugar.Errorf("[TRX][%s] API response indicates failure", address)
- return
- }
-
- transfers := gjson.GetBytes(resp.Body(), "data").Array()
- if len(transfers) == 0 {
- log.Sugar.Debugf("[TRX][%s] no transfer records found", address)
- return
- }
- log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
-
- for i, transfer := range transfers {
- if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
- continue
- }
- if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
- continue
- }
-
- toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
- toBytes, err := hex.DecodeString(toAddressHex)
- if err != nil {
- log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
- continue
- }
- if tron.EncodeCheck(toBytes) != address {
- continue
- }
-
- 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.New(1, int32(trxDecimals))).InexactFloat64(), 2)
- if trxCfg.MinAmount > 0 && amount < trxCfg.MinAmount {
- continue
- }
- if amount <= 0 {
- continue
- }
-
- txID := transfer.Get("txID").String()
- tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)), amount)
- if err != nil {
- log.Sugar.Errorf("[TRX][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
- continue
- }
- if tradeID == "" {
- log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
- continue
- }
- log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
-
- order, err := data.GetOrderInfoByTradeId(tradeID)
- if err != nil {
- log.Sugar.Errorf("[TRX][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
- continue
- }
- blockTimestamp := transfer.Get("block_timestamp").Int()
- createTime := order.CreatedAt.TimestampMilli()
- 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{
- ReceiveAddress: address,
- Token: strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)),
- Network: mdb.NetworkTron,
- 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("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
- continue
- }
- log.Sugar.Errorf("[TRX][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
- continue
- }
-
- sendPaymentNotification(order)
- log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
- }
-}
-
-func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
- defer wg.Done()
-
- // Build contract -> token map for the TRON network. If nothing is
- // configured, skip — preserves the previous behavior of only watching
- // admin-approved tokens.
- tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTron)
- if err != nil {
- log.Sugar.Errorf("[TRC20][%s] load chain_tokens err=%v", address, err)
- return
- }
- if len(tokens) == 0 {
- log.Sugar.Debugf("[TRC20][%s] no enabled chain_tokens, skipping", address)
- return
- }
- contractTokens := make(map[string]*mdb.ChainToken, len(tokens))
- for i := range tokens {
- c := strings.TrimSpace(tokens[i].ContractAddress)
- if c == "" {
- continue
- }
- contractTokens[c] = &tokens[i]
- }
-
- client := http_client.GetHttpClient()
- startTime := carbon.Now().AddHours(-24).TimestampMilli()
- endTime := carbon.Now().TimestampMilli()
- tronBaseURL, tronAPIKey, err := resolveTronNode()
- if err != nil {
- log.Sugar.Errorf("[TRC20][%s] resolve rpc_nodes err=%v", address, err)
- return
- }
- url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, 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", tronAPIKey).Get(url)
- if err != nil {
- log.Sugar.Errorf("[TRC20][%s] HTTP request failed: %v", address, err)
- return
- }
- if resp.StatusCode() != http.StatusOK {
- log.Sugar.Errorf("[TRC20][%s] API returned status %d", address, resp.StatusCode())
- return
- }
-
- success := gjson.GetBytes(resp.Body(), "success").Bool()
- if !success {
- log.Sugar.Errorf("[TRC20][%s] API response indicates failure", address)
- return
- }
-
- transfers := gjson.GetBytes(resp.Body(), "data").Array()
- if len(transfers) == 0 {
- log.Sugar.Debugf("[TRC20][%s] no transfer records found", address)
- return
- }
- log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
-
- for i, transfer := range transfers {
- contractAddr := transfer.Get("token_info.address").String()
- cfg, ok := contractTokens[contractAddr]
- if !ok {
- continue
- }
- if transfer.Get("to").String() != address {
- continue
- }
- tokenSym := strings.ToUpper(strings.TrimSpace(cfg.Symbol))
-
- 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()
- if tokenDecimals <= 0 {
- tokenDecimals = int64(cfg.Decimals)
- }
- amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
- if cfg.MinAmount > 0 && amount < cfg.MinAmount {
- continue
- }
- if amount <= 0 {
- continue
- }
-
- txID := transfer.Get("transaction_id").String()
- tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, tokenSym, amount)
- if err != nil {
- log.Sugar.Errorf("[TRC20][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
- continue
- }
- if tradeID == "" {
- log.Sugar.Debugf("[TRC20][%s] skip unmatched %s tx hash=%s amount=%.2f", address, tokenSym, txID, amount)
- continue
- }
- log.Sugar.Infof("[TRC20][%s] matched %s trade_id=%s hash=%s amount=%.2f", address, tokenSym, tradeID, txID, amount)
-
- order, err := data.GetOrderInfoByTradeId(tradeID)
- if err != nil {
- log.Sugar.Errorf("[TRC20][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
- continue
- }
- 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: tokenSym,
- Network: mdb.NetworkTron,
- 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
- }
- log.Sugar.Errorf("[TRC20][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
- continue
- }
-
- sendPaymentNotification(order)
- log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
- }
+ sendPaymentNotification(order)
+ log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
}
func evmChainLogLabel(chainNetwork string) string {
@@ -349,9 +172,6 @@ func evmChainLogLabel(chainNetwork string) string {
}
}
-// TryProcessEvmERC20Transfer 处理各 EVM 链上代币的 Transfer 入账。
-// 代币识别、符号和 decimals 全部从 chain_tokens 表动态查询 —
-// 管理后台新增 token 即可立即生效,无需代码改动。
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
@@ -359,38 +179,46 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
}
}()
- net := evmChainLogLabel(chainNetwork)
- token, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
- if err != nil {
- log.Sugar.Warnf("[%s-WS] chain_tokens lookup err=%v contract=%s", net, err, contract.Hex())
+ var usdt, usdc common.Address
+ var polygonUsdcE common.Address
+ switch chainNetwork {
+ case mdb.NetworkEthereum:
+ usdt = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
+ usdc = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
+ case mdb.NetworkBsc:
+ usdt = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
+ usdc = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
+ case mdb.NetworkPolygon:
+ usdt = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F")
+ usdc = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359")
+ polygonUsdcE = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
+ case mdb.NetworkPlasma:
+ // USDT0(官方),6 decimals;链上暂无与 ETH 同级的 Circle USDC 部署,仅匹配 USDT 订单
+ usdt = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
+ default:
return
}
- if token == nil || token.ID == 0 {
- log.Sugar.Debugf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
- return
- }
- tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol))
- if tokenSym == "" {
- log.Sugar.Warnf("[%s-WS] chain_token id=%d has empty symbol", net, token.ID)
- return
- }
- decimals := token.Decimals
- if decimals <= 0 {
- decimals = 6
- }
+ var tokenSym string
+ switch {
+ case contract == usdt:
+ tokenSym = "USDT"
+ case contract == usdc || (polygonUsdcE != (common.Address{}) && contract == polygonUsdcE):
+ tokenSym = "USDC"
+ default:
+ net := evmChainLogLabel(chainNetwork)
+ log.Sugar.Warnf("[%s-WS] skip unsupported contract %s", net, contract.Hex())
+ return
+ }
+
+ net := evmChainLogLabel(chainNetwork)
walletAddr := strings.ToLower(toAddr.Hex())
if rawValue == nil || rawValue.Sign() <= 0 {
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
return
}
- divisor := decimal.New(1, int32(decimals))
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
- amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
- if token.MinAmount > 0 && amount < token.MinAmount {
- log.Sugar.Debugf("[%s-%s][%s] skip amount %.2f below min_amount %.2f", net, tokenSym, walletAddr, amount, token.MinAmount)
- return
- }
+ amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
if amount <= 0 {
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
return
@@ -421,11 +249,6 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
return
}
- if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
- log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block_time_ms=%d is before order created_ms=%d",
- net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
- return
- }
req := &request.OrderProcessingRequest{
ReceiveAddress: walletAddr,
diff --git a/src/task/listen.go b/src/task/listen.go
index e99b99f..36b4a05 100644
--- a/src/task/listen.go
+++ b/src/task/listen.go
@@ -13,26 +13,11 @@ func Start() {
go StartBscWebSocketListener()
go StartPolygonWebSocketListener()
go StartPlasmaWebSocketListener()
+ go StartTronBlockScannerListener()
c := cron.New()
- // TRC20 polling
- _, 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)")
- // Solana polling
- _, 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)")
// RPC node health checks
- _, err = c.AddJob("@every 30s", RpcHealthJob{})
+ _, err := c.AddJob("@every 30s", RpcHealthJob{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add RpcHealthJob: %v", err)
return
diff --git a/src/task/listen_trc20_block.go b/src/task/listen_trc20_block.go
new file mode 100644
index 0000000..dea5509
--- /dev/null
+++ b/src/task/listen_trc20_block.go
@@ -0,0 +1,378 @@
+package task
+
+import (
+ "bytes"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math/big"
+ "net/http"
+ "strings"
+ "time"
+
+ tron "github.com/GMWalletApp/epusdt/crypto"
+ "github.com/GMWalletApp/epusdt/model/service"
+ "github.com/GMWalletApp/epusdt/util/log"
+)
+
+const (
+ // USDT 合约地址 (TRC20 主网)
+ USDTContractHex = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" // Base58: TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
+ // transfer(address,uint256) 方法签名前4字节
+ TransferMethodID = "a9059cbb"
+
+ PollInterval = 3 * time.Second
+ RequestTimeout = 10 * time.Second
+)
+
+func HexToTronAddress(hexAddr string) (string, error) {
+ hexAddr = strings.TrimPrefix(hexAddr, "0x")
+ hexAddr = strings.TrimPrefix(hexAddr, "0X")
+
+ raw, err := hex.DecodeString(hexAddr)
+ if err != nil {
+ return "", err
+ }
+
+ // 确保有 0x41 前缀(TRON 主网地址前缀)
+ if len(raw) == 20 {
+ raw = append([]byte{0x41}, raw...)
+ }
+ if len(raw) != 21 {
+ return "", fmt.Errorf("地址长度非法: %d bytes", len(raw))
+ }
+
+ return tron.EncodeCheck(raw), nil
+}
+
+type BlockHeader struct {
+ RawData struct {
+ Number int64 `json:"number"`
+ Timestamp int64 `json:"timestamp"`
+ WitnessAddress string `json:"witness_address"`
+ ParentHash string `json:"parentHash"`
+ Version int `json:"version"`
+ } `json:"raw_data"`
+}
+
+type TriggerSmartContractValue struct {
+ OwnerAddress string `json:"owner_address"`
+ ContractAddress string `json:"contract_address"`
+ Data string `json:"data"`
+ CallValue int64 `json:"call_value"`
+}
+
+type ContractParam struct {
+ TypeURL string `json:"type_url"`
+ Value json.RawMessage `json:"value"`
+}
+
+type Transaction struct {
+ TxID string `json:"txID"`
+ RawData struct {
+ Contract []struct {
+ Type string `json:"type"`
+ Parameter ContractParam `json:"parameter"`
+ } `json:"contract"`
+ Timestamp int64 `json:"timestamp"`
+ FeeLimit int64 `json:"fee_limit"`
+ } `json:"raw_data"`
+ Ret []struct {
+ ContractRet string `json:"contractRet"`
+ } `json:"ret"`
+}
+
+type Block struct {
+ BlockID string `json:"blockID"`
+ BlockHeader BlockHeader `json:"block_header"`
+ Transactions []Transaction `json:"transactions"`
+}
+
+type USDTTransfer struct {
+ TxID string
+ From string
+ To string
+ Raw *big.Int // 原始数值(6 decimals)
+ Status string
+}
+
+type TRXTransfer struct {
+ TxID string
+ From string
+ To string
+ RawSun int64 // 单位: SUN
+ Status string
+}
+
+type TransferContractValue struct {
+ OwnerAddress string `json:"owner_address"`
+ ToAddress string `json:"to_address"`
+ Amount int64 `json:"amount"` // 单位: SUN
+}
+
+var httpClient = &http.Client{Timeout: RequestTimeout}
+
+func doPost(url string, apiKey string, body interface{}) ([]byte, error) {
+ data, _ := json.Marshal(body)
+ req, err := http.NewRequest("POST", url, bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
+ req.Header.Set("TRON-PRO-API-KEY", apiKey)
+ }
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ b, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b))
+ }
+ return b, nil
+}
+
+func GetNowBlock(baseURL string, apiKey string) (*Block, error) {
+ b, err := doPost(baseURL+"/wallet/getnowblock", apiKey, map[string]interface{}{})
+ if err != nil {
+ return nil, err
+ }
+ var block Block
+ return &block, json.Unmarshal(b, &block)
+}
+
+func GetBlockByNum(baseURL string, apiKey string, num int64) (*Block, error) {
+ b, err := doPost(baseURL+"/wallet/getblockbynum", apiKey, map[string]interface{}{"num": num})
+ if err != nil {
+ return nil, err
+ }
+ var block Block
+ return &block, json.Unmarshal(b, &block)
+}
+
+func parseUSDTTransfer(tx Transaction) *USDTTransfer {
+ if len(tx.RawData.Contract) == 0 {
+ return nil
+ }
+ c := tx.RawData.Contract[0]
+ if c.Type != "TriggerSmartContract" {
+ return nil
+ }
+
+ var val TriggerSmartContractValue
+ if err := json.Unmarshal(c.Parameter.Value, &val); err != nil {
+ return nil
+ }
+
+ // 检查是否是 USDT 合约
+ contractHex := strings.ToLower(strings.TrimPrefix(val.ContractAddress, "0x"))
+ if contractHex != USDTContractHex {
+ return nil
+ }
+
+ // 解析 data 字段
+ // 格式: [4字节方法ID][32字节 to 地址][32字节 amount]
+ data := strings.TrimPrefix(strings.ToLower(val.Data), "0x")
+ if len(data) < 8+64+64 {
+ return nil
+ }
+ if data[:8] != TransferMethodID {
+ return nil
+ }
+
+ // to 地址:后 40 个十六进制字符(20 字节)
+ toHex := data[8+24 : 8+64] // 跳过前12字节填充,取后20字节
+ toAddr, err := HexToTronAddress(toHex)
+ if err != nil {
+ return nil
+ }
+
+ // amount:后 32 字节大整数
+ amountHex := data[8+64 : 8+64+64]
+ amountBytes, err := hex.DecodeString(amountHex)
+ if err != nil {
+ return nil
+ }
+ amountBig := new(big.Int).SetBytes(amountBytes)
+
+ // from 地址
+ fromAddr, err := HexToTronAddress(val.OwnerAddress)
+ if err != nil {
+ fromAddr = val.OwnerAddress
+ }
+
+ // 交易状态
+ status := "SUCCESS"
+ if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
+ status = tx.Ret[0].ContractRet
+ }
+
+ return &USDTTransfer{
+ TxID: tx.TxID,
+ From: fromAddr,
+ To: toAddr,
+ Raw: amountBig,
+ Status: status,
+ }
+}
+
+func parseTRXTransfer(tx Transaction) *TRXTransfer {
+ if len(tx.RawData.Contract) == 0 {
+ return nil
+ }
+ c := tx.RawData.Contract[0]
+ if c.Type != "TransferContract" {
+ return nil
+ }
+
+ var val TransferContractValue
+ if err := json.Unmarshal(c.Parameter.Value, &val); err != nil {
+ return nil
+ }
+ if val.Amount <= 0 {
+ return nil
+ }
+
+ fromAddr, err := HexToTronAddress(val.OwnerAddress)
+ if err != nil {
+ fromAddr = val.OwnerAddress
+ }
+ toAddr, err := HexToTronAddress(val.ToAddress)
+ if err != nil {
+ toAddr = val.ToAddress
+ }
+
+ status := "SUCCESS"
+ if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
+ status = tx.Ret[0].ContractRet
+ }
+
+ return &TRXTransfer{
+ TxID: tx.TxID,
+ From: fromAddr,
+ To: toAddr,
+ RawSun: val.Amount,
+ Status: status,
+ }
+}
+
+func processBlock(block *Block) {
+ blockTsMs := block.BlockHeader.RawData.Timestamp
+ for _, tx := range block.Transactions {
+ if t := parseUSDTTransfer(tx); t != nil {
+ if t.Status != "SUCCESS" {
+ continue
+ }
+ service.TryProcessTronTRC20Transfer(t.To, t.Raw, t.TxID, blockTsMs)
+ continue
+ }
+ if t := parseTRXTransfer(tx); t != nil {
+ if t.Status != "SUCCESS" {
+ continue
+ }
+ service.TryProcessTronTRXTransfer(t.To, t.RawSun, t.TxID, blockTsMs)
+ }
+ }
+}
+
+type Scanner struct {
+ baseURL string
+ apiKey string
+ lastBlock int64
+ // 统计
+ totalBlocks int64
+ totalUSDTTxs int64
+ totalTRXTxs int64
+}
+
+func NewScanner() *Scanner {
+ return &Scanner{}
+}
+
+func (s *Scanner) Init() error {
+ baseURL, apiKey, err := service.ResolveTronNode()
+ if err != nil {
+ return fmt.Errorf("resolve tron node: %w", err)
+ }
+ s.baseURL = baseURL
+ s.apiKey = apiKey
+
+ log.Sugar.Infof("[TRON-BLOCK] node=%s", s.baseURL)
+ block, err := GetNowBlock(s.baseURL, s.apiKey)
+ if err != nil {
+ return fmt.Errorf("获取初始块失败: %w", err)
+ }
+ s.lastBlock = block.BlockHeader.RawData.Number
+ log.Sugar.Infof("[TRON-BLOCK] start block=%d", s.lastBlock)
+ return nil
+}
+
+func (s *Scanner) Run() {
+ log.Sugar.Info("[TRON-BLOCK] start scanning (USDT TRC20 + TRX)")
+ ticker := time.NewTicker(PollInterval)
+ defer ticker.Stop()
+
+ statTicker := time.NewTicker(60 * time.Second)
+ defer statTicker.Stop()
+
+ for {
+ select {
+ case <-statTicker.C:
+ log.Sugar.Infof("[TRON-BLOCK] stats blocks=%d usdt=%d trx=%d", s.totalBlocks, s.totalUSDTTxs, s.totalTRXTxs)
+ case <-ticker.C:
+ s.poll()
+ }
+ }
+}
+
+func (s *Scanner) poll() {
+ latest, err := GetNowBlock(s.baseURL, s.apiKey)
+ if err != nil {
+ log.Sugar.Warnf("[TRON-BLOCK] get latest block: %v", err)
+ return
+ }
+ latestNum := latest.BlockHeader.RawData.Number
+ if latestNum <= s.lastBlock {
+ return
+ }
+
+ for num := s.lastBlock + 1; num <= latestNum; num++ {
+ var block *Block
+ if num == latestNum {
+ block = latest
+ } else {
+ block, err = GetBlockByNum(s.baseURL, s.apiKey, num)
+ if err != nil {
+ log.Sugar.Warnf("[TRON-BLOCK] get block %d: %v", num, err)
+ continue
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ processBlock(block)
+ s.lastBlock = num
+ s.totalBlocks++
+
+ for _, tx := range block.Transactions {
+ if parseUSDTTransfer(tx) != nil {
+ s.totalUSDTTxs++
+ } else if parseTRXTransfer(tx) != nil {
+ s.totalTRXTxs++
+ }
+ }
+ }
+}
+
+func StartTronBlockScannerListener() {
+ scanner := NewScanner()
+ if err := scanner.Init(); err != nil {
+ log.Sugar.Errorf("[TRON-BLOCK] init: %v", err)
+ return
+ }
+ scanner.Run()
+}
diff --git a/src/task/listen_trc20_job.go b/src/task/listen_trc20_job.go
deleted file mode 100644
index eb8b832..0000000
--- a/src/task/listen_trc20_job.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package task
-
-import (
- "sync"
-
- "github.com/GMWalletApp/epusdt/model/data"
- "github.com/GMWalletApp/epusdt/model/mdb"
- "github.com/GMWalletApp/epusdt/model/service"
- "github.com/GMWalletApp/epusdt/util/log"
-)
-
-type ListenTrc20Job struct{}
-
-var gListenTrc20JobLock sync.Mutex
-
-func (r ListenTrc20Job) Run() {
- gListenTrc20JobLock.Lock()
- defer gListenTrc20JobLock.Unlock()
- log.Sugar.Debug("[ListenTrc20Job] Job triggered")
- if !data.IsChainEnabled(mdb.NetworkTron) {
- log.Sugar.Debug("[ListenTrc20Job] chain disabled, skipping")
- return
- }
- walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
- if err != nil {
- 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.Address, &wg)
- }
- wg.Wait()
- log.Sugar.Debug("[ListenTrc20Job] Job completed")
-}
From 74c3a3a08cf4a3114118bbe0b397630e22c2da61 Mon Sep 17 00:00:00 2001
From: Ginta <775650117@qq.com>
Date: Mon, 27 Apr 2026 00:04:05 +0800
Subject: [PATCH 2/8] Revert "new trc (#54)"
This reverts commit 0a19854602ed9748c9282ed82e49f0ef75929f4d.
---
src/model/service/task_service.go | 463 +++++++++++++++++++++---------
src/task/listen.go | 19 +-
src/task/listen_trc20_block.go | 378 ------------------------
src/task/listen_trc20_job.go | 43 +++
4 files changed, 380 insertions(+), 523 deletions(-)
delete mode 100644 src/task/listen_trc20_block.go
create mode 100644 src/task/listen_trc20_job.go
diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go
index 6ff16be..8e6c0d2 100644
--- a/src/model/service/task_service.go
+++ b/src/model/service/task_service.go
@@ -1,23 +1,32 @@
package service
import (
+ "encoding/hex"
"errors"
"fmt"
"math/big"
+ "net/http"
"strings"
+ "sync"
+ tron "github.com/GMWalletApp/epusdt/crypto"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/notify"
"github.com/GMWalletApp/epusdt/util/constant"
+ "github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common"
+ "github.com/gookit/goutil/stdutil"
"github.com/shopspring/decimal"
+ "github.com/tidwall/gjson"
)
+// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
+// It reads the first healthy (or any enabled) row from the rpc_nodes table.
func resolveTronNode() (string, string, error) {
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
@@ -33,128 +42,296 @@ func resolveTronNode() (string, string, error) {
return rpcURL, node.ApiKey, nil
}
-func ResolveTronNode() (string, string, error) {
- return resolveTronNode()
-}
-
-func TryProcessTronTRC20Transfer(toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
+func Trc20CallBack(address string, wg *sync.WaitGroup) {
+ defer wg.Done()
defer func() {
if err := recover(); err != nil {
- log.Sugar.Errorf("[TRC20][%s] TryProcessTronTRC20Transfer panic: %v", toAddr, err)
+ log.Sugar.Error(err)
}
}()
- addr := strings.TrimSpace(toAddr)
- if addr == "" || rawValue == nil || rawValue.Sign() <= 0 {
- return
- }
-
- decimalQuant := decimal.NewFromBigInt(rawValue, 0)
- amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
- if amount <= 0 {
- return
- }
-
- tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "USDT", amount)
- if err != nil {
- log.Sugar.Warnf("[TRC20][%s] lock lookup: %v", addr, err)
- return
- }
- if tradeID == "" {
- log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
- return
- }
-
- order, err := data.GetOrderInfoByTradeId(tradeID)
- if err != nil {
- log.Sugar.Warnf("[TRC20][%s] load order: %v", addr, err)
- return
- }
- if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
- log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
- return
- }
-
- req := &request.OrderProcessingRequest{
- ReceiveAddress: addr,
- Token: "USDT",
- Network: mdb.NetworkTron,
- TradeId: tradeID,
- Amount: amount,
- BlockTransactionId: txHash,
- }
- 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", addr, tradeID, txHash, err)
- return
- }
- log.Sugar.Errorf("[TRC20][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
- return
- }
-
- sendPaymentNotification(order)
- log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
+ var innerWg sync.WaitGroup
+ innerWg.Add(2)
+ go checkTrxTransfers(address, &innerWg)
+ go checkTrc20Transfers(address, &innerWg)
+ innerWg.Wait()
}
-func TryProcessTronTRXTransfer(toAddr string, rawSun int64, txHash string, blockTsMs int64) {
- defer func() {
- if err := recover(); err != nil {
- log.Sugar.Errorf("[TRX][%s] TryProcessTronTRXTransfer panic: %v", toAddr, err)
+func checkTrxTransfers(address string, wg *sync.WaitGroup) {
+ defer wg.Done()
+
+ // Native TRX is gated by a chain_tokens row with empty
+ // contract_address and symbol=TRX. Admin can disable this row to
+ // stop scanning native transfers without touching the chain toggle.
+ trxCfg, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, "TRX")
+ if err != nil {
+ log.Sugar.Errorf("[TRX][%s] load chain_tokens err=%v", address, err)
+ return
+ }
+ if trxCfg == nil || trxCfg.ID == 0 {
+ log.Sugar.Debugf("[TRX][%s] native TRX disabled in chain_tokens, skipping", address)
+ return
+ }
+ trxDecimals := trxCfg.Decimals
+ if trxDecimals <= 0 {
+ trxDecimals = 6
+ }
+
+ client := http_client.GetHttpClient()
+ startTime := carbon.Now().AddHours(-24).TimestampMilli()
+ endTime := carbon.Now().TimestampMilli()
+ tronBaseURL, tronAPIKey, err := resolveTronNode()
+ if err != nil {
+ log.Sugar.Errorf("[TRX][%s] resolve rpc_nodes err=%v", address, err)
+ return
+ }
+ url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, 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", tronAPIKey).Get(url)
+ if err != nil {
+ log.Sugar.Errorf("[TRX][%s] HTTP request failed: %v", address, err)
+ return
+ }
+ if resp.StatusCode() != http.StatusOK {
+ log.Sugar.Errorf("[TRX][%s] API returned status %d", address, resp.StatusCode())
+ return
+ }
+
+ success := gjson.GetBytes(resp.Body(), "success").Bool()
+ if !success {
+ log.Sugar.Errorf("[TRX][%s] API response indicates failure", address)
+ return
+ }
+
+ transfers := gjson.GetBytes(resp.Body(), "data").Array()
+ if len(transfers) == 0 {
+ log.Sugar.Debugf("[TRX][%s] no transfer records found", address)
+ return
+ }
+ log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
+
+ for i, transfer := range transfers {
+ if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
+ continue
}
- }()
-
- addr := strings.TrimSpace(toAddr)
- if addr == "" || rawSun <= 0 {
- return
- }
-
- decimalQuant := decimal.NewFromInt(rawSun)
- amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
- if amount <= 0 {
- return
- }
-
- tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "TRX", amount)
- if err != nil {
- log.Sugar.Warnf("[TRX][%s] lock lookup: %v", addr, err)
- return
- }
- if tradeID == "" {
- log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
- return
- }
-
- order, err := data.GetOrderInfoByTradeId(tradeID)
- if err != nil {
- log.Sugar.Warnf("[TRX][%s] load order: %v", addr, err)
- return
- }
- if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
- log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
- return
- }
-
- req := &request.OrderProcessingRequest{
- ReceiveAddress: addr,
- Token: "TRX",
- Network: mdb.NetworkTron,
- TradeId: tradeID,
- Amount: amount,
- BlockTransactionId: txHash,
- }
- err = OrderProcessing(req)
- if err != nil {
- if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
- log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
- return
+ if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
+ continue
}
- log.Sugar.Errorf("[TRX][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
+
+ toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
+ toBytes, err := hex.DecodeString(toAddressHex)
+ if err != nil {
+ log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
+ continue
+ }
+ if tron.EncodeCheck(toBytes) != address {
+ continue
+ }
+
+ 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.New(1, int32(trxDecimals))).InexactFloat64(), 2)
+ if trxCfg.MinAmount > 0 && amount < trxCfg.MinAmount {
+ continue
+ }
+ if amount <= 0 {
+ continue
+ }
+
+ txID := transfer.Get("txID").String()
+ tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)), amount)
+ if err != nil {
+ log.Sugar.Errorf("[TRX][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
+ continue
+ }
+ if tradeID == "" {
+ log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
+ continue
+ }
+ log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
+
+ order, err := data.GetOrderInfoByTradeId(tradeID)
+ if err != nil {
+ log.Sugar.Errorf("[TRX][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
+ continue
+ }
+ blockTimestamp := transfer.Get("block_timestamp").Int()
+ createTime := order.CreatedAt.TimestampMilli()
+ 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{
+ ReceiveAddress: address,
+ Token: strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)),
+ Network: mdb.NetworkTron,
+ 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("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
+ continue
+ }
+ log.Sugar.Errorf("[TRX][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
+ continue
+ }
+
+ sendPaymentNotification(order)
+ log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
+ }
+}
+
+func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
+ defer wg.Done()
+
+ // Build contract -> token map for the TRON network. If nothing is
+ // configured, skip — preserves the previous behavior of only watching
+ // admin-approved tokens.
+ tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTron)
+ if err != nil {
+ log.Sugar.Errorf("[TRC20][%s] load chain_tokens err=%v", address, err)
+ return
+ }
+ if len(tokens) == 0 {
+ log.Sugar.Debugf("[TRC20][%s] no enabled chain_tokens, skipping", address)
+ return
+ }
+ contractTokens := make(map[string]*mdb.ChainToken, len(tokens))
+ for i := range tokens {
+ c := strings.TrimSpace(tokens[i].ContractAddress)
+ if c == "" {
+ continue
+ }
+ contractTokens[c] = &tokens[i]
+ }
+
+ client := http_client.GetHttpClient()
+ startTime := carbon.Now().AddHours(-24).TimestampMilli()
+ endTime := carbon.Now().TimestampMilli()
+ tronBaseURL, tronAPIKey, err := resolveTronNode()
+ if err != nil {
+ log.Sugar.Errorf("[TRC20][%s] resolve rpc_nodes err=%v", address, err)
+ return
+ }
+ url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, 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", tronAPIKey).Get(url)
+ if err != nil {
+ log.Sugar.Errorf("[TRC20][%s] HTTP request failed: %v", address, err)
+ return
+ }
+ if resp.StatusCode() != http.StatusOK {
+ log.Sugar.Errorf("[TRC20][%s] API returned status %d", address, resp.StatusCode())
return
}
- sendPaymentNotification(order)
- log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
+ success := gjson.GetBytes(resp.Body(), "success").Bool()
+ if !success {
+ log.Sugar.Errorf("[TRC20][%s] API response indicates failure", address)
+ return
+ }
+
+ transfers := gjson.GetBytes(resp.Body(), "data").Array()
+ if len(transfers) == 0 {
+ log.Sugar.Debugf("[TRC20][%s] no transfer records found", address)
+ return
+ }
+ log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
+
+ for i, transfer := range transfers {
+ contractAddr := transfer.Get("token_info.address").String()
+ cfg, ok := contractTokens[contractAddr]
+ if !ok {
+ continue
+ }
+ if transfer.Get("to").String() != address {
+ continue
+ }
+ tokenSym := strings.ToUpper(strings.TrimSpace(cfg.Symbol))
+
+ 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()
+ if tokenDecimals <= 0 {
+ tokenDecimals = int64(cfg.Decimals)
+ }
+ amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
+ if cfg.MinAmount > 0 && amount < cfg.MinAmount {
+ continue
+ }
+ if amount <= 0 {
+ continue
+ }
+
+ txID := transfer.Get("transaction_id").String()
+ tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, tokenSym, amount)
+ if err != nil {
+ log.Sugar.Errorf("[TRC20][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
+ continue
+ }
+ if tradeID == "" {
+ log.Sugar.Debugf("[TRC20][%s] skip unmatched %s tx hash=%s amount=%.2f", address, tokenSym, txID, amount)
+ continue
+ }
+ log.Sugar.Infof("[TRC20][%s] matched %s trade_id=%s hash=%s amount=%.2f", address, tokenSym, tradeID, txID, amount)
+
+ order, err := data.GetOrderInfoByTradeId(tradeID)
+ if err != nil {
+ log.Sugar.Errorf("[TRC20][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
+ continue
+ }
+ 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: tokenSym,
+ Network: mdb.NetworkTron,
+ 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
+ }
+ log.Sugar.Errorf("[TRC20][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
+ continue
+ }
+
+ sendPaymentNotification(order)
+ log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
+ }
}
func evmChainLogLabel(chainNetwork string) string {
@@ -172,6 +349,9 @@ func evmChainLogLabel(chainNetwork string) string {
}
}
+// TryProcessEvmERC20Transfer 处理各 EVM 链上代币的 Transfer 入账。
+// 代币识别、符号和 decimals 全部从 chain_tokens 表动态查询 —
+// 管理后台新增 token 即可立即生效,无需代码改动。
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
@@ -179,46 +359,38 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
}
}()
- var usdt, usdc common.Address
- var polygonUsdcE common.Address
- switch chainNetwork {
- case mdb.NetworkEthereum:
- usdt = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
- usdc = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
- case mdb.NetworkBsc:
- usdt = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
- usdc = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
- case mdb.NetworkPolygon:
- usdt = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F")
- usdc = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359")
- polygonUsdcE = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
- case mdb.NetworkPlasma:
- // USDT0(官方),6 decimals;链上暂无与 ETH 同级的 Circle USDC 部署,仅匹配 USDT 订单
- usdt = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
- default:
- return
- }
-
- var tokenSym string
- switch {
- case contract == usdt:
- tokenSym = "USDT"
- case contract == usdc || (polygonUsdcE != (common.Address{}) && contract == polygonUsdcE):
- tokenSym = "USDC"
- default:
- net := evmChainLogLabel(chainNetwork)
- log.Sugar.Warnf("[%s-WS] skip unsupported contract %s", net, contract.Hex())
- return
- }
-
net := evmChainLogLabel(chainNetwork)
+ token, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
+ if err != nil {
+ log.Sugar.Warnf("[%s-WS] chain_tokens lookup err=%v contract=%s", net, err, contract.Hex())
+ return
+ }
+ if token == nil || token.ID == 0 {
+ log.Sugar.Debugf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
+ return
+ }
+ tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol))
+ if tokenSym == "" {
+ log.Sugar.Warnf("[%s-WS] chain_token id=%d has empty symbol", net, token.ID)
+ return
+ }
+ decimals := token.Decimals
+ if decimals <= 0 {
+ decimals = 6
+ }
+
walletAddr := strings.ToLower(toAddr.Hex())
if rawValue == nil || rawValue.Sign() <= 0 {
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
return
}
+ divisor := decimal.New(1, int32(decimals))
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
- amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
+ amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
+ if token.MinAmount > 0 && amount < token.MinAmount {
+ log.Sugar.Debugf("[%s-%s][%s] skip amount %.2f below min_amount %.2f", net, tokenSym, walletAddr, amount, token.MinAmount)
+ return
+ }
if amount <= 0 {
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
return
@@ -249,6 +421,11 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
return
}
+ if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
+ log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block_time_ms=%d is before order created_ms=%d",
+ net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
+ return
+ }
req := &request.OrderProcessingRequest{
ReceiveAddress: walletAddr,
diff --git a/src/task/listen.go b/src/task/listen.go
index 36b4a05..e99b99f 100644
--- a/src/task/listen.go
+++ b/src/task/listen.go
@@ -13,11 +13,26 @@ func Start() {
go StartBscWebSocketListener()
go StartPolygonWebSocketListener()
go StartPlasmaWebSocketListener()
- go StartTronBlockScannerListener()
c := cron.New()
+ // TRC20 polling
+ _, 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)")
+ // Solana polling
+ _, 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)")
// RPC node health checks
- _, err := c.AddJob("@every 30s", RpcHealthJob{})
+ _, err = c.AddJob("@every 30s", RpcHealthJob{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add RpcHealthJob: %v", err)
return
diff --git a/src/task/listen_trc20_block.go b/src/task/listen_trc20_block.go
deleted file mode 100644
index dea5509..0000000
--- a/src/task/listen_trc20_block.go
+++ /dev/null
@@ -1,378 +0,0 @@
-package task
-
-import (
- "bytes"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "io"
- "math/big"
- "net/http"
- "strings"
- "time"
-
- tron "github.com/GMWalletApp/epusdt/crypto"
- "github.com/GMWalletApp/epusdt/model/service"
- "github.com/GMWalletApp/epusdt/util/log"
-)
-
-const (
- // USDT 合约地址 (TRC20 主网)
- USDTContractHex = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" // Base58: TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
- // transfer(address,uint256) 方法签名前4字节
- TransferMethodID = "a9059cbb"
-
- PollInterval = 3 * time.Second
- RequestTimeout = 10 * time.Second
-)
-
-func HexToTronAddress(hexAddr string) (string, error) {
- hexAddr = strings.TrimPrefix(hexAddr, "0x")
- hexAddr = strings.TrimPrefix(hexAddr, "0X")
-
- raw, err := hex.DecodeString(hexAddr)
- if err != nil {
- return "", err
- }
-
- // 确保有 0x41 前缀(TRON 主网地址前缀)
- if len(raw) == 20 {
- raw = append([]byte{0x41}, raw...)
- }
- if len(raw) != 21 {
- return "", fmt.Errorf("地址长度非法: %d bytes", len(raw))
- }
-
- return tron.EncodeCheck(raw), nil
-}
-
-type BlockHeader struct {
- RawData struct {
- Number int64 `json:"number"`
- Timestamp int64 `json:"timestamp"`
- WitnessAddress string `json:"witness_address"`
- ParentHash string `json:"parentHash"`
- Version int `json:"version"`
- } `json:"raw_data"`
-}
-
-type TriggerSmartContractValue struct {
- OwnerAddress string `json:"owner_address"`
- ContractAddress string `json:"contract_address"`
- Data string `json:"data"`
- CallValue int64 `json:"call_value"`
-}
-
-type ContractParam struct {
- TypeURL string `json:"type_url"`
- Value json.RawMessage `json:"value"`
-}
-
-type Transaction struct {
- TxID string `json:"txID"`
- RawData struct {
- Contract []struct {
- Type string `json:"type"`
- Parameter ContractParam `json:"parameter"`
- } `json:"contract"`
- Timestamp int64 `json:"timestamp"`
- FeeLimit int64 `json:"fee_limit"`
- } `json:"raw_data"`
- Ret []struct {
- ContractRet string `json:"contractRet"`
- } `json:"ret"`
-}
-
-type Block struct {
- BlockID string `json:"blockID"`
- BlockHeader BlockHeader `json:"block_header"`
- Transactions []Transaction `json:"transactions"`
-}
-
-type USDTTransfer struct {
- TxID string
- From string
- To string
- Raw *big.Int // 原始数值(6 decimals)
- Status string
-}
-
-type TRXTransfer struct {
- TxID string
- From string
- To string
- RawSun int64 // 单位: SUN
- Status string
-}
-
-type TransferContractValue struct {
- OwnerAddress string `json:"owner_address"`
- ToAddress string `json:"to_address"`
- Amount int64 `json:"amount"` // 单位: SUN
-}
-
-var httpClient = &http.Client{Timeout: RequestTimeout}
-
-func doPost(url string, apiKey string, body interface{}) ([]byte, error) {
- data, _ := json.Marshal(body)
- req, err := http.NewRequest("POST", url, bytes.NewReader(data))
- if err != nil {
- return nil, err
- }
- req.Header.Set("Content-Type", "application/json")
- req.Header.Set("Accept", "application/json")
- if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
- req.Header.Set("TRON-PRO-API-KEY", apiKey)
- }
-
- resp, err := httpClient.Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
-
- b, _ := io.ReadAll(resp.Body)
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b))
- }
- return b, nil
-}
-
-func GetNowBlock(baseURL string, apiKey string) (*Block, error) {
- b, err := doPost(baseURL+"/wallet/getnowblock", apiKey, map[string]interface{}{})
- if err != nil {
- return nil, err
- }
- var block Block
- return &block, json.Unmarshal(b, &block)
-}
-
-func GetBlockByNum(baseURL string, apiKey string, num int64) (*Block, error) {
- b, err := doPost(baseURL+"/wallet/getblockbynum", apiKey, map[string]interface{}{"num": num})
- if err != nil {
- return nil, err
- }
- var block Block
- return &block, json.Unmarshal(b, &block)
-}
-
-func parseUSDTTransfer(tx Transaction) *USDTTransfer {
- if len(tx.RawData.Contract) == 0 {
- return nil
- }
- c := tx.RawData.Contract[0]
- if c.Type != "TriggerSmartContract" {
- return nil
- }
-
- var val TriggerSmartContractValue
- if err := json.Unmarshal(c.Parameter.Value, &val); err != nil {
- return nil
- }
-
- // 检查是否是 USDT 合约
- contractHex := strings.ToLower(strings.TrimPrefix(val.ContractAddress, "0x"))
- if contractHex != USDTContractHex {
- return nil
- }
-
- // 解析 data 字段
- // 格式: [4字节方法ID][32字节 to 地址][32字节 amount]
- data := strings.TrimPrefix(strings.ToLower(val.Data), "0x")
- if len(data) < 8+64+64 {
- return nil
- }
- if data[:8] != TransferMethodID {
- return nil
- }
-
- // to 地址:后 40 个十六进制字符(20 字节)
- toHex := data[8+24 : 8+64] // 跳过前12字节填充,取后20字节
- toAddr, err := HexToTronAddress(toHex)
- if err != nil {
- return nil
- }
-
- // amount:后 32 字节大整数
- amountHex := data[8+64 : 8+64+64]
- amountBytes, err := hex.DecodeString(amountHex)
- if err != nil {
- return nil
- }
- amountBig := new(big.Int).SetBytes(amountBytes)
-
- // from 地址
- fromAddr, err := HexToTronAddress(val.OwnerAddress)
- if err != nil {
- fromAddr = val.OwnerAddress
- }
-
- // 交易状态
- status := "SUCCESS"
- if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
- status = tx.Ret[0].ContractRet
- }
-
- return &USDTTransfer{
- TxID: tx.TxID,
- From: fromAddr,
- To: toAddr,
- Raw: amountBig,
- Status: status,
- }
-}
-
-func parseTRXTransfer(tx Transaction) *TRXTransfer {
- if len(tx.RawData.Contract) == 0 {
- return nil
- }
- c := tx.RawData.Contract[0]
- if c.Type != "TransferContract" {
- return nil
- }
-
- var val TransferContractValue
- if err := json.Unmarshal(c.Parameter.Value, &val); err != nil {
- return nil
- }
- if val.Amount <= 0 {
- return nil
- }
-
- fromAddr, err := HexToTronAddress(val.OwnerAddress)
- if err != nil {
- fromAddr = val.OwnerAddress
- }
- toAddr, err := HexToTronAddress(val.ToAddress)
- if err != nil {
- toAddr = val.ToAddress
- }
-
- status := "SUCCESS"
- if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
- status = tx.Ret[0].ContractRet
- }
-
- return &TRXTransfer{
- TxID: tx.TxID,
- From: fromAddr,
- To: toAddr,
- RawSun: val.Amount,
- Status: status,
- }
-}
-
-func processBlock(block *Block) {
- blockTsMs := block.BlockHeader.RawData.Timestamp
- for _, tx := range block.Transactions {
- if t := parseUSDTTransfer(tx); t != nil {
- if t.Status != "SUCCESS" {
- continue
- }
- service.TryProcessTronTRC20Transfer(t.To, t.Raw, t.TxID, blockTsMs)
- continue
- }
- if t := parseTRXTransfer(tx); t != nil {
- if t.Status != "SUCCESS" {
- continue
- }
- service.TryProcessTronTRXTransfer(t.To, t.RawSun, t.TxID, blockTsMs)
- }
- }
-}
-
-type Scanner struct {
- baseURL string
- apiKey string
- lastBlock int64
- // 统计
- totalBlocks int64
- totalUSDTTxs int64
- totalTRXTxs int64
-}
-
-func NewScanner() *Scanner {
- return &Scanner{}
-}
-
-func (s *Scanner) Init() error {
- baseURL, apiKey, err := service.ResolveTronNode()
- if err != nil {
- return fmt.Errorf("resolve tron node: %w", err)
- }
- s.baseURL = baseURL
- s.apiKey = apiKey
-
- log.Sugar.Infof("[TRON-BLOCK] node=%s", s.baseURL)
- block, err := GetNowBlock(s.baseURL, s.apiKey)
- if err != nil {
- return fmt.Errorf("获取初始块失败: %w", err)
- }
- s.lastBlock = block.BlockHeader.RawData.Number
- log.Sugar.Infof("[TRON-BLOCK] start block=%d", s.lastBlock)
- return nil
-}
-
-func (s *Scanner) Run() {
- log.Sugar.Info("[TRON-BLOCK] start scanning (USDT TRC20 + TRX)")
- ticker := time.NewTicker(PollInterval)
- defer ticker.Stop()
-
- statTicker := time.NewTicker(60 * time.Second)
- defer statTicker.Stop()
-
- for {
- select {
- case <-statTicker.C:
- log.Sugar.Infof("[TRON-BLOCK] stats blocks=%d usdt=%d trx=%d", s.totalBlocks, s.totalUSDTTxs, s.totalTRXTxs)
- case <-ticker.C:
- s.poll()
- }
- }
-}
-
-func (s *Scanner) poll() {
- latest, err := GetNowBlock(s.baseURL, s.apiKey)
- if err != nil {
- log.Sugar.Warnf("[TRON-BLOCK] get latest block: %v", err)
- return
- }
- latestNum := latest.BlockHeader.RawData.Number
- if latestNum <= s.lastBlock {
- return
- }
-
- for num := s.lastBlock + 1; num <= latestNum; num++ {
- var block *Block
- if num == latestNum {
- block = latest
- } else {
- block, err = GetBlockByNum(s.baseURL, s.apiKey, num)
- if err != nil {
- log.Sugar.Warnf("[TRON-BLOCK] get block %d: %v", num, err)
- continue
- }
- time.Sleep(200 * time.Millisecond)
- }
- processBlock(block)
- s.lastBlock = num
- s.totalBlocks++
-
- for _, tx := range block.Transactions {
- if parseUSDTTransfer(tx) != nil {
- s.totalUSDTTxs++
- } else if parseTRXTransfer(tx) != nil {
- s.totalTRXTxs++
- }
- }
- }
-}
-
-func StartTronBlockScannerListener() {
- scanner := NewScanner()
- if err := scanner.Init(); err != nil {
- log.Sugar.Errorf("[TRON-BLOCK] init: %v", err)
- return
- }
- scanner.Run()
-}
diff --git a/src/task/listen_trc20_job.go b/src/task/listen_trc20_job.go
new file mode 100644
index 0000000..eb8b832
--- /dev/null
+++ b/src/task/listen_trc20_job.go
@@ -0,0 +1,43 @@
+package task
+
+import (
+ "sync"
+
+ "github.com/GMWalletApp/epusdt/model/data"
+ "github.com/GMWalletApp/epusdt/model/mdb"
+ "github.com/GMWalletApp/epusdt/model/service"
+ "github.com/GMWalletApp/epusdt/util/log"
+)
+
+type ListenTrc20Job struct{}
+
+var gListenTrc20JobLock sync.Mutex
+
+func (r ListenTrc20Job) Run() {
+ gListenTrc20JobLock.Lock()
+ defer gListenTrc20JobLock.Unlock()
+ log.Sugar.Debug("[ListenTrc20Job] Job triggered")
+ if !data.IsChainEnabled(mdb.NetworkTron) {
+ log.Sugar.Debug("[ListenTrc20Job] chain disabled, skipping")
+ return
+ }
+ walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
+ if err != nil {
+ 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.Address, &wg)
+ }
+ wg.Wait()
+ log.Sugar.Debug("[ListenTrc20Job] Job completed")
+}
From 9e885d8b2febefa38c6efcd1269e2ae12836a81e Mon Sep 17 00:00:00 2001
From: Ginta <775650117@qq.com>
Date: Mon, 27 Apr 2026 15:43:35 +0800
Subject: [PATCH 3/8] order notify
---
src/model/service/task_service.go | 14 ++++-
src/model/service/task_service_test.go | 79 ++++++++++++++++++++++++++
2 files changed, 92 insertions(+), 1 deletion(-)
create mode 100644 src/model/service/task_service_test.go
diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go
index 8e6c0d2..85498bc 100644
--- a/src/model/service/task_service.go
+++ b/src/model/service/task_service.go
@@ -450,6 +450,18 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
}
func sendPaymentNotification(order *mdb.Orders) {
+ if order == nil {
+ return
+ }
+ if strings.TrimSpace(order.TradeId) != "" {
+ latest, err := data.GetOrderInfoByTradeId(order.TradeId)
+ if err != nil {
+ log.Sugar.Warnf("[notify] reload order failed trade_id=%s err=%v", order.TradeId, err)
+ } else if latest != nil && latest.TradeId != "" {
+ order = latest
+ }
+ }
+
msg := fmt.Sprintf(
"🎉 收款成功通知\n\n"+
"💰 金额信息\n"+
@@ -472,7 +484,7 @@ func sendPaymentNotification(order *mdb.Orders) {
networkDisplay(order.Network),
order.ReceiveAddress,
order.CreatedAt.ToDateTimeString(),
- carbon.Now().ToDateTimeString(),
+ order.UpdatedAt.ToDateTimeString(),
)
notify.Dispatch(mdb.NotifyEventPaySuccess, msg)
}
diff --git a/src/model/service/task_service_test.go b/src/model/service/task_service_test.go
new file mode 100644
index 0000000..6a2134a
--- /dev/null
+++ b/src/model/service/task_service_test.go
@@ -0,0 +1,79 @@
+package service
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/GMWalletApp/epusdt/internal/testutil"
+ "github.com/GMWalletApp/epusdt/model/dao"
+ "github.com/GMWalletApp/epusdt/model/mdb"
+ "github.com/GMWalletApp/epusdt/notify"
+)
+
+func TestSendPaymentNotificationUsesLatestOrderUpdatedAt(t *testing.T) {
+ cleanup := testutil.SetupTestDatabases(t)
+ defer cleanup()
+
+ const channelType = "test-pay-success-time"
+ got := make(chan string, 1)
+ notify.RegisterSender(channelType, func(config, text string) error {
+ got <- text
+ return nil
+ })
+
+ if err := dao.Mdb.Create(&mdb.NotificationChannel{
+ Type: channelType,
+ Name: "test",
+ Config: "{}",
+ Events: `{"pay_success":true}`,
+ Enabled: true,
+ }).Error; err != nil {
+ t.Fatalf("seed notification channel: %v", err)
+ }
+
+ order := &mdb.Orders{
+ TradeId: "T202604270001",
+ OrderId: "ORD202604270001",
+ Amount: 100,
+ Currency: "cny",
+ ActualAmount: 14.28,
+ Token: "USDT",
+ Network: mdb.NetworkTron,
+ ReceiveAddress: "TTestAddress",
+ Status: mdb.StatusWaitPay,
+ }
+ if err := dao.Mdb.Create(order).Error; err != nil {
+ t.Fatalf("seed order: %v", err)
+ }
+
+ const createdAt = "2026-04-27 09:00:00"
+ const staleUpdatedAt = "2026-04-27 09:01:00"
+ const paidAt = "2026-04-27 10:20:30"
+ if err := dao.Mdb.Exec("UPDATE orders SET created_at = ?, updated_at = ? WHERE trade_id = ?", createdAt, staleUpdatedAt, order.TradeId).Error; err != nil {
+ t.Fatalf("set initial timestamps: %v", err)
+ }
+
+ var staleOrderModel mdb.Orders
+ if err := dao.Mdb.Where("trade_id = ?", order.TradeId).Take(&staleOrderModel).Error; err != nil {
+ t.Fatalf("load stale order: %v", err)
+ }
+
+ if err := dao.Mdb.Exec("UPDATE orders SET status = ?, updated_at = ? WHERE trade_id = ?", mdb.StatusPaySuccess, paidAt, order.TradeId).Error; err != nil {
+ t.Fatalf("set paid timestamp: %v", err)
+ }
+
+ sendPaymentNotification(&staleOrderModel)
+
+ select {
+ case text := <-got:
+ if !strings.Contains(text, "支付时间:"+paidAt) {
+ t.Fatalf("notification payment time = %q, want %s", text, paidAt)
+ }
+ if strings.Contains(text, "支付时间:"+staleUpdatedAt) {
+ t.Fatalf("notification used stale payment time: %q", text)
+ }
+ case <-time.After(2 * time.Second):
+ t.Fatal("timed out waiting for notification")
+ }
+}
From de9c45a944c3053af8afe142ea933763c55058ad Mon Sep 17 00:00:00 2001
From: Ginta <775650117@qq.com>
Date: Mon, 27 Apr 2026 15:52:12 +0800
Subject: [PATCH 4/8] static
---
src/config/config.go | 91 +++++++++++++++++++++++++++++++++++++++
src/config/config_test.go | 54 +++++++++++++++++++++++
2 files changed, 145 insertions(+)
diff --git a/src/config/config.go b/src/config/config.go
index c8852d9..e998a89 100644
--- a/src/config/config.go
+++ b/src/config/config.go
@@ -3,6 +3,7 @@ package config
import (
"errors"
"fmt"
+ "io"
"log"
"net/url"
"os"
@@ -62,6 +63,9 @@ func Init() {
LogLevel = normalizeLogLevel(viper.GetString("log_level"))
StaticPath = normalizeStaticURLPath(viper.GetString("static_path"))
StaticFilePath = filepath.Join(configRootPath, strings.TrimPrefix(StaticPath, "/"))
+ if err = ensureConfiguredStaticFiles(); err != nil {
+ panic(err)
+ }
RuntimePath = resolvePathFromBase(configRootPath, viper.GetString("runtime_root_path"), filepath.Join(configRootPath, "runtime"))
LogSavePath = resolvePathFromBase(RuntimePath, viper.GetString("log_save_path"), filepath.Join(RuntimePath, "logs"))
mustMkdir(RuntimePath)
@@ -82,6 +86,93 @@ func mustMkdir(path string) {
}
}
+func ensureConfiguredStaticFiles() error {
+ if strings.TrimSpace(StaticFilePath) == "" {
+ return nil
+ }
+ exePath, err := os.Executable()
+ if err != nil {
+ return err
+ }
+ exePath, err = filepath.EvalSymlinks(exePath)
+ if err != nil {
+ return err
+ }
+
+ srcDir := filepath.Join(filepath.Dir(exePath), "static")
+ srcInfo, err := os.Stat(srcDir)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return err
+ }
+ if !srcInfo.IsDir() {
+ return nil
+ }
+
+ srcAbs, err := filepath.Abs(srcDir)
+ if err != nil {
+ return err
+ }
+ dstAbs, err := filepath.Abs(StaticFilePath)
+ if err != nil {
+ return err
+ }
+ if srcAbs == dstAbs {
+ return nil
+ }
+
+ return copyMissingStaticFiles(srcAbs, dstAbs)
+}
+
+func copyMissingStaticFiles(srcDir, dstDir string) error {
+ return filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ rel, err := filepath.Rel(srcDir, path)
+ if err != nil {
+ return err
+ }
+ dstPath := filepath.Join(dstDir, rel)
+ if d.IsDir() {
+ return os.MkdirAll(dstPath, 0o755)
+ }
+ if _, err = os.Stat(dstPath); err == nil {
+ return nil
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ if err = os.MkdirAll(filepath.Dir(dstPath), 0o755); err != nil {
+ return err
+ }
+ return copyFile(path, dstPath)
+ })
+}
+
+func copyFile(srcPath, dstPath string) error {
+ in, err := os.Open(srcPath)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
+ if err != nil {
+ if errors.Is(err, os.ErrExist) {
+ return nil
+ }
+ return err
+ }
+
+ if _, err = io.Copy(out, in); err != nil {
+ _ = out.Close()
+ return err
+ }
+ return out.Close()
+}
+
func normalizeLogLevel(level string) string {
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug", "info", "warn", "error":
diff --git a/src/config/config_test.go b/src/config/config_test.go
index 2b8e880..88738d0 100644
--- a/src/config/config_test.go
+++ b/src/config/config_test.go
@@ -177,6 +177,60 @@ func TestResolveConfigFilePathPrefersExplicitOverEnv(t *testing.T) {
}
}
+func TestCopyMissingStaticFilesCopiesAssetsWithoutOverwriting(t *testing.T) {
+ root := t.TempDir()
+ src := filepath.Join(root, "app-static")
+ dst := filepath.Join(root, "data-static")
+
+ if err := os.MkdirAll(filepath.Join(src, "images"), 0o755); err != nil {
+ t.Fatalf("mkdir source: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "index.html"), []byte("source-index"), 0o644); err != nil {
+ t.Fatalf("write source index: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "payment.js"), []byte("source-payment"), 0o644); err != nil {
+ t.Fatalf("write source payment: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(src, "images", "logo.png"), []byte("source-logo"), 0o644); err != nil {
+ t.Fatalf("write source logo: %v", err)
+ }
+
+ if err := os.MkdirAll(dst, 0o755); err != nil {
+ t.Fatalf("mkdir destination: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(dst, "index.html"), []byte("custom-index"), 0o644); err != nil {
+ t.Fatalf("write existing destination index: %v", err)
+ }
+
+ if err := copyMissingStaticFiles(src, dst); err != nil {
+ t.Fatalf("copy missing static files: %v", err)
+ }
+
+ index, err := os.ReadFile(filepath.Join(dst, "index.html"))
+ if err != nil {
+ t.Fatalf("read destination index: %v", err)
+ }
+ if string(index) != "custom-index" {
+ t.Fatalf("existing index was overwritten: %q", string(index))
+ }
+
+ payment, err := os.ReadFile(filepath.Join(dst, "payment.js"))
+ if err != nil {
+ t.Fatalf("read copied payment: %v", err)
+ }
+ if string(payment) != "source-payment" {
+ t.Fatalf("payment.js = %q, want source-payment", string(payment))
+ }
+
+ logo, err := os.ReadFile(filepath.Join(dst, "images", "logo.png"))
+ if err != nil {
+ t.Fatalf("read copied nested asset: %v", err)
+ }
+ if string(logo) != "source-logo" {
+ t.Fatalf("logo.png = %q, want source-logo", string(logo))
+ }
+}
+
func TestGetUsdtRatePrefersPositiveAdminOverride(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
From 96ea7481bc6feeffd0f48cfa834890977b08c531 Mon Sep 17 00:00:00 2001
From: Ginta <775650117@qq.com>
Date: Mon, 27 Apr 2026 15:52:50 +0800
Subject: [PATCH 5/8] Revert "Revert "new trc (#54)""
This reverts commit 74c3a3a08cf4a3114118bbe0b397630e22c2da61.
---
src/model/service/task_service.go | 439 +++++++++---------------------
src/task/listen.go | 19 +-
src/task/listen_trc20_block.go | 378 +++++++++++++++++++++++++
src/task/listen_trc20_job.go | 43 ---
4 files changed, 511 insertions(+), 368 deletions(-)
create mode 100644 src/task/listen_trc20_block.go
delete mode 100644 src/task/listen_trc20_job.go
diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go
index 85498bc..52ebc1b 100644
--- a/src/model/service/task_service.go
+++ b/src/model/service/task_service.go
@@ -1,32 +1,23 @@
package service
import (
- "encoding/hex"
"errors"
"fmt"
"math/big"
- "net/http"
"strings"
- "sync"
- tron "github.com/GMWalletApp/epusdt/crypto"
"github.com/GMWalletApp/epusdt/model/data"
"github.com/GMWalletApp/epusdt/model/mdb"
"github.com/GMWalletApp/epusdt/model/request"
"github.com/GMWalletApp/epusdt/notify"
"github.com/GMWalletApp/epusdt/util/constant"
- "github.com/GMWalletApp/epusdt/util/http_client"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
"github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common"
- "github.com/gookit/goutil/stdutil"
"github.com/shopspring/decimal"
- "github.com/tidwall/gjson"
)
-// resolveTronNode returns (baseURL, apiKey) for the TRON HTTP RPC node.
-// It reads the first healthy (or any enabled) row from the rpc_nodes table.
func resolveTronNode() (string, string, error) {
node, err := data.SelectRpcNode(mdb.NetworkTron, mdb.RpcNodeTypeHttp)
if err != nil {
@@ -42,296 +33,128 @@ func resolveTronNode() (string, string, error) {
return rpcURL, node.ApiKey, nil
}
-func Trc20CallBack(address string, wg *sync.WaitGroup) {
- defer wg.Done()
+func ResolveTronNode() (string, string, error) {
+ return resolveTronNode()
+}
+
+func TryProcessTronTRC20Transfer(toAddr string, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
- log.Sugar.Error(err)
+ log.Sugar.Errorf("[TRC20][%s] TryProcessTronTRC20Transfer panic: %v", toAddr, err)
}
}()
- var innerWg sync.WaitGroup
- innerWg.Add(2)
- go checkTrxTransfers(address, &innerWg)
- go checkTrc20Transfers(address, &innerWg)
- innerWg.Wait()
+ addr := strings.TrimSpace(toAddr)
+ if addr == "" || rawValue == nil || rawValue.Sign() <= 0 {
+ return
+ }
+
+ decimalQuant := decimal.NewFromBigInt(rawValue, 0)
+ amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
+ if amount <= 0 {
+ return
+ }
+
+ tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "USDT", amount)
+ if err != nil {
+ log.Sugar.Warnf("[TRC20][%s] lock lookup: %v", addr, err)
+ return
+ }
+ if tradeID == "" {
+ log.Sugar.Debugf("[TRC20][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
+ return
+ }
+
+ order, err := data.GetOrderInfoByTradeId(tradeID)
+ if err != nil {
+ log.Sugar.Warnf("[TRC20][%s] load order: %v", addr, err)
+ return
+ }
+ if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
+ log.Sugar.Warnf("[TRC20][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
+ return
+ }
+
+ req := &request.OrderProcessingRequest{
+ ReceiveAddress: addr,
+ Token: "USDT",
+ Network: mdb.NetworkTron,
+ TradeId: tradeID,
+ Amount: amount,
+ BlockTransactionId: txHash,
+ }
+ 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", addr, tradeID, txHash, err)
+ return
+ }
+ log.Sugar.Errorf("[TRC20][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
+ return
+ }
+
+ sendPaymentNotification(order)
+ log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
}
-func checkTrxTransfers(address string, wg *sync.WaitGroup) {
- defer wg.Done()
+func TryProcessTronTRXTransfer(toAddr string, rawSun int64, txHash string, blockTsMs int64) {
+ defer func() {
+ if err := recover(); err != nil {
+ log.Sugar.Errorf("[TRX][%s] TryProcessTronTRXTransfer panic: %v", toAddr, err)
+ }
+ }()
- // Native TRX is gated by a chain_tokens row with empty
- // contract_address and symbol=TRX. Admin can disable this row to
- // stop scanning native transfers without touching the chain toggle.
- trxCfg, err := data.GetEnabledChainTokenBySymbol(mdb.NetworkTron, "TRX")
+ addr := strings.TrimSpace(toAddr)
+ if addr == "" || rawSun <= 0 {
+ return
+ }
+
+ decimalQuant := decimal.NewFromInt(rawSun)
+ amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
+ if amount <= 0 {
+ return
+ }
+
+ tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, addr, "TRX", amount)
if err != nil {
- log.Sugar.Errorf("[TRX][%s] load chain_tokens err=%v", address, err)
+ log.Sugar.Warnf("[TRX][%s] lock lookup: %v", addr, err)
return
}
- if trxCfg == nil || trxCfg.ID == 0 {
- log.Sugar.Debugf("[TRX][%s] native TRX disabled in chain_tokens, skipping", address)
+ if tradeID == "" {
+ log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", addr, txHash, amount)
return
}
- trxDecimals := trxCfg.Decimals
- if trxDecimals <= 0 {
- trxDecimals = 6
- }
- client := http_client.GetHttpClient()
- startTime := carbon.Now().AddHours(-24).TimestampMilli()
- endTime := carbon.Now().TimestampMilli()
- tronBaseURL, tronAPIKey, err := resolveTronNode()
+ order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil {
- log.Sugar.Errorf("[TRX][%s] resolve rpc_nodes err=%v", address, err)
+ log.Sugar.Warnf("[TRX][%s] load order: %v", addr, err)
+ return
+ }
+ if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
+ log.Sugar.Warnf("[TRX][%s] skip tx %s because block time %d is before order create time %d", addr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
return
}
- url := fmt.Sprintf("%s/v1/accounts/%s/transactions", tronBaseURL, 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", tronAPIKey).Get(url)
+ req := &request.OrderProcessingRequest{
+ ReceiveAddress: addr,
+ Token: "TRX",
+ Network: mdb.NetworkTron,
+ TradeId: tradeID,
+ Amount: amount,
+ BlockTransactionId: txHash,
+ }
+ err = OrderProcessing(req)
if err != nil {
- log.Sugar.Errorf("[TRX][%s] HTTP request failed: %v", address, err)
- return
- }
- if resp.StatusCode() != http.StatusOK {
- log.Sugar.Errorf("[TRX][%s] API returned status %d", address, resp.StatusCode())
+ if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) {
+ log.Sugar.Infof("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", addr, tradeID, txHash, err)
+ return
+ }
+ log.Sugar.Errorf("[TRX][%s] OrderProcessing trade_id=%s hash=%s: %v", addr, tradeID, txHash, err)
return
}
- success := gjson.GetBytes(resp.Body(), "success").Bool()
- if !success {
- log.Sugar.Errorf("[TRX][%s] API response indicates failure", address)
- return
- }
-
- transfers := gjson.GetBytes(resp.Body(), "data").Array()
- if len(transfers) == 0 {
- log.Sugar.Debugf("[TRX][%s] no transfer records found", address)
- return
- }
- log.Sugar.Debugf("[TRX][%s] fetched %d transfer records", address, len(transfers))
-
- for i, transfer := range transfers {
- if transfer.Get("raw_data.contract.0.type").String() != "TransferContract" {
- continue
- }
- if transfer.Get("ret.0.contractRet").String() != "SUCCESS" {
- continue
- }
-
- toAddressHex := transfer.Get("raw_data.contract.0.parameter.value.to_address").String()
- toBytes, err := hex.DecodeString(toAddressHex)
- if err != nil {
- log.Sugar.Errorf("[TRX][%s] decode address failed on tx #%d: %v", address, i, err)
- continue
- }
- if tron.EncodeCheck(toBytes) != address {
- continue
- }
-
- 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.New(1, int32(trxDecimals))).InexactFloat64(), 2)
- if trxCfg.MinAmount > 0 && amount < trxCfg.MinAmount {
- continue
- }
- if amount <= 0 {
- continue
- }
-
- txID := transfer.Get("txID").String()
- tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)), amount)
- if err != nil {
- log.Sugar.Errorf("[TRX][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
- continue
- }
- if tradeID == "" {
- log.Sugar.Debugf("[TRX][%s] skip unmatched tx hash=%s amount=%.2f", address, txID, amount)
- continue
- }
- log.Sugar.Infof("[TRX][%s] matched trade_id=%s hash=%s amount=%.2f", address, tradeID, txID, amount)
-
- order, err := data.GetOrderInfoByTradeId(tradeID)
- if err != nil {
- log.Sugar.Errorf("[TRX][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
- continue
- }
- blockTimestamp := transfer.Get("block_timestamp").Int()
- createTime := order.CreatedAt.TimestampMilli()
- 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{
- ReceiveAddress: address,
- Token: strings.ToUpper(strings.TrimSpace(trxCfg.Symbol)),
- Network: mdb.NetworkTron,
- 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("[TRX][%s] skip resolved transfer trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
- continue
- }
- log.Sugar.Errorf("[TRX][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
- continue
- }
-
- sendPaymentNotification(order)
- log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
- }
-}
-
-func checkTrc20Transfers(address string, wg *sync.WaitGroup) {
- defer wg.Done()
-
- // Build contract -> token map for the TRON network. If nothing is
- // configured, skip — preserves the previous behavior of only watching
- // admin-approved tokens.
- tokens, err := data.ListEnabledChainTokensByNetwork(mdb.NetworkTron)
- if err != nil {
- log.Sugar.Errorf("[TRC20][%s] load chain_tokens err=%v", address, err)
- return
- }
- if len(tokens) == 0 {
- log.Sugar.Debugf("[TRC20][%s] no enabled chain_tokens, skipping", address)
- return
- }
- contractTokens := make(map[string]*mdb.ChainToken, len(tokens))
- for i := range tokens {
- c := strings.TrimSpace(tokens[i].ContractAddress)
- if c == "" {
- continue
- }
- contractTokens[c] = &tokens[i]
- }
-
- client := http_client.GetHttpClient()
- startTime := carbon.Now().AddHours(-24).TimestampMilli()
- endTime := carbon.Now().TimestampMilli()
- tronBaseURL, tronAPIKey, err := resolveTronNode()
- if err != nil {
- log.Sugar.Errorf("[TRC20][%s] resolve rpc_nodes err=%v", address, err)
- return
- }
- url := fmt.Sprintf("%s/v1/accounts/%s/transactions/trc20", tronBaseURL, 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", tronAPIKey).Get(url)
- if err != nil {
- log.Sugar.Errorf("[TRC20][%s] HTTP request failed: %v", address, err)
- return
- }
- if resp.StatusCode() != http.StatusOK {
- log.Sugar.Errorf("[TRC20][%s] API returned status %d", address, resp.StatusCode())
- return
- }
-
- success := gjson.GetBytes(resp.Body(), "success").Bool()
- if !success {
- log.Sugar.Errorf("[TRC20][%s] API response indicates failure", address)
- return
- }
-
- transfers := gjson.GetBytes(resp.Body(), "data").Array()
- if len(transfers) == 0 {
- log.Sugar.Debugf("[TRC20][%s] no transfer records found", address)
- return
- }
- log.Sugar.Debugf("[TRC20][%s] fetched %d transfer records", address, len(transfers))
-
- for i, transfer := range transfers {
- contractAddr := transfer.Get("token_info.address").String()
- cfg, ok := contractTokens[contractAddr]
- if !ok {
- continue
- }
- if transfer.Get("to").String() != address {
- continue
- }
- tokenSym := strings.ToUpper(strings.TrimSpace(cfg.Symbol))
-
- 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()
- if tokenDecimals <= 0 {
- tokenDecimals = int64(cfg.Decimals)
- }
- amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.New(1, int32(tokenDecimals))).InexactFloat64(), 2)
- if cfg.MinAmount > 0 && amount < cfg.MinAmount {
- continue
- }
- if amount <= 0 {
- continue
- }
-
- txID := transfer.Get("transaction_id").String()
- tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkTron, address, tokenSym, amount)
- if err != nil {
- log.Sugar.Errorf("[TRC20][%s] lookup trade_id failed hash=%s err=%v", address, txID, err)
- continue
- }
- if tradeID == "" {
- log.Sugar.Debugf("[TRC20][%s] skip unmatched %s tx hash=%s amount=%.2f", address, tokenSym, txID, amount)
- continue
- }
- log.Sugar.Infof("[TRC20][%s] matched %s trade_id=%s hash=%s amount=%.2f", address, tokenSym, tradeID, txID, amount)
-
- order, err := data.GetOrderInfoByTradeId(tradeID)
- if err != nil {
- log.Sugar.Errorf("[TRC20][%s] get order failed trade_id=%s err=%v", address, tradeID, err)
- continue
- }
- 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: tokenSym,
- Network: mdb.NetworkTron,
- 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
- }
- log.Sugar.Errorf("[TRC20][%s] order processing failed trade_id=%s hash=%s err=%v", address, tradeID, txID, err)
- continue
- }
-
- sendPaymentNotification(order)
- log.Sugar.Infof("[TRC20][%s] payment processed trade_id=%s hash=%s", address, tradeID, txID)
- }
+ sendPaymentNotification(order)
+ log.Sugar.Infof("[TRX][%s] payment processed trade_id=%s hash=%s", addr, tradeID, txHash)
}
func evmChainLogLabel(chainNetwork string) string {
@@ -349,9 +172,6 @@ func evmChainLogLabel(chainNetwork string) string {
}
}
-// TryProcessEvmERC20Transfer 处理各 EVM 链上代币的 Transfer 入账。
-// 代币识别、符号和 decimals 全部从 chain_tokens 表动态查询 —
-// 管理后台新增 token 即可立即生效,无需代码改动。
func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) {
defer func() {
if err := recover(); err != nil {
@@ -359,38 +179,46 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
}
}()
- net := evmChainLogLabel(chainNetwork)
- token, err := data.GetEnabledChainTokenByContract(chainNetwork, contract.Hex())
- if err != nil {
- log.Sugar.Warnf("[%s-WS] chain_tokens lookup err=%v contract=%s", net, err, contract.Hex())
+ var usdt, usdc common.Address
+ var polygonUsdcE common.Address
+ switch chainNetwork {
+ case mdb.NetworkEthereum:
+ usdt = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
+ usdc = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
+ case mdb.NetworkBsc:
+ usdt = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
+ usdc = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
+ case mdb.NetworkPolygon:
+ usdt = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F")
+ usdc = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359")
+ polygonUsdcE = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174")
+ case mdb.NetworkPlasma:
+ // USDT0(官方),6 decimals;链上暂无与 ETH 同级的 Circle USDC 部署,仅匹配 USDT 订单
+ usdt = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
+ default:
return
}
- if token == nil || token.ID == 0 {
- log.Sugar.Debugf("[%s-WS] skip unconfigured contract %s", net, contract.Hex())
- return
- }
- tokenSym := strings.ToUpper(strings.TrimSpace(token.Symbol))
- if tokenSym == "" {
- log.Sugar.Warnf("[%s-WS] chain_token id=%d has empty symbol", net, token.ID)
- return
- }
- decimals := token.Decimals
- if decimals <= 0 {
- decimals = 6
- }
+ var tokenSym string
+ switch {
+ case contract == usdt:
+ tokenSym = "USDT"
+ case contract == usdc || (polygonUsdcE != (common.Address{}) && contract == polygonUsdcE):
+ tokenSym = "USDC"
+ default:
+ net := evmChainLogLabel(chainNetwork)
+ log.Sugar.Warnf("[%s-WS] skip unsupported contract %s", net, contract.Hex())
+ return
+ }
+
+ net := evmChainLogLabel(chainNetwork)
walletAddr := strings.ToLower(toAddr.Hex())
if rawValue == nil || rawValue.Sign() <= 0 {
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
return
}
- divisor := decimal.New(1, int32(decimals))
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
- amount := math.MustParsePrecFloat64(decimalQuant.Div(divisor).InexactFloat64(), 2)
- if token.MinAmount > 0 && amount < token.MinAmount {
- log.Sugar.Debugf("[%s-%s][%s] skip amount %.2f below min_amount %.2f", net, tokenSym, walletAddr, amount, token.MinAmount)
- return
- }
+ amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
if amount <= 0 {
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
return
@@ -421,11 +249,6 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token)
return
}
- if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() {
- log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block_time_ms=%d is before order created_ms=%d",
- net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli())
- return
- }
req := &request.OrderProcessingRequest{
ReceiveAddress: walletAddr,
diff --git a/src/task/listen.go b/src/task/listen.go
index e99b99f..36b4a05 100644
--- a/src/task/listen.go
+++ b/src/task/listen.go
@@ -13,26 +13,11 @@ func Start() {
go StartBscWebSocketListener()
go StartPolygonWebSocketListener()
go StartPlasmaWebSocketListener()
+ go StartTronBlockScannerListener()
c := cron.New()
- // TRC20 polling
- _, 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)")
- // Solana polling
- _, 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)")
// RPC node health checks
- _, err = c.AddJob("@every 30s", RpcHealthJob{})
+ _, err := c.AddJob("@every 30s", RpcHealthJob{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add RpcHealthJob: %v", err)
return
diff --git a/src/task/listen_trc20_block.go b/src/task/listen_trc20_block.go
new file mode 100644
index 0000000..dea5509
--- /dev/null
+++ b/src/task/listen_trc20_block.go
@@ -0,0 +1,378 @@
+package task
+
+import (
+ "bytes"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "math/big"
+ "net/http"
+ "strings"
+ "time"
+
+ tron "github.com/GMWalletApp/epusdt/crypto"
+ "github.com/GMWalletApp/epusdt/model/service"
+ "github.com/GMWalletApp/epusdt/util/log"
+)
+
+const (
+ // USDT 合约地址 (TRC20 主网)
+ USDTContractHex = "41a614f803b6fd780986a42c78ec9c7f77e6ded13c" // Base58: TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t
+ // transfer(address,uint256) 方法签名前4字节
+ TransferMethodID = "a9059cbb"
+
+ PollInterval = 3 * time.Second
+ RequestTimeout = 10 * time.Second
+)
+
+func HexToTronAddress(hexAddr string) (string, error) {
+ hexAddr = strings.TrimPrefix(hexAddr, "0x")
+ hexAddr = strings.TrimPrefix(hexAddr, "0X")
+
+ raw, err := hex.DecodeString(hexAddr)
+ if err != nil {
+ return "", err
+ }
+
+ // 确保有 0x41 前缀(TRON 主网地址前缀)
+ if len(raw) == 20 {
+ raw = append([]byte{0x41}, raw...)
+ }
+ if len(raw) != 21 {
+ return "", fmt.Errorf("地址长度非法: %d bytes", len(raw))
+ }
+
+ return tron.EncodeCheck(raw), nil
+}
+
+type BlockHeader struct {
+ RawData struct {
+ Number int64 `json:"number"`
+ Timestamp int64 `json:"timestamp"`
+ WitnessAddress string `json:"witness_address"`
+ ParentHash string `json:"parentHash"`
+ Version int `json:"version"`
+ } `json:"raw_data"`
+}
+
+type TriggerSmartContractValue struct {
+ OwnerAddress string `json:"owner_address"`
+ ContractAddress string `json:"contract_address"`
+ Data string `json:"data"`
+ CallValue int64 `json:"call_value"`
+}
+
+type ContractParam struct {
+ TypeURL string `json:"type_url"`
+ Value json.RawMessage `json:"value"`
+}
+
+type Transaction struct {
+ TxID string `json:"txID"`
+ RawData struct {
+ Contract []struct {
+ Type string `json:"type"`
+ Parameter ContractParam `json:"parameter"`
+ } `json:"contract"`
+ Timestamp int64 `json:"timestamp"`
+ FeeLimit int64 `json:"fee_limit"`
+ } `json:"raw_data"`
+ Ret []struct {
+ ContractRet string `json:"contractRet"`
+ } `json:"ret"`
+}
+
+type Block struct {
+ BlockID string `json:"blockID"`
+ BlockHeader BlockHeader `json:"block_header"`
+ Transactions []Transaction `json:"transactions"`
+}
+
+type USDTTransfer struct {
+ TxID string
+ From string
+ To string
+ Raw *big.Int // 原始数值(6 decimals)
+ Status string
+}
+
+type TRXTransfer struct {
+ TxID string
+ From string
+ To string
+ RawSun int64 // 单位: SUN
+ Status string
+}
+
+type TransferContractValue struct {
+ OwnerAddress string `json:"owner_address"`
+ ToAddress string `json:"to_address"`
+ Amount int64 `json:"amount"` // 单位: SUN
+}
+
+var httpClient = &http.Client{Timeout: RequestTimeout}
+
+func doPost(url string, apiKey string, body interface{}) ([]byte, error) {
+ data, _ := json.Marshal(body)
+ req, err := http.NewRequest("POST", url, bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+ if apiKey = strings.TrimSpace(apiKey); apiKey != "" {
+ req.Header.Set("TRON-PRO-API-KEY", apiKey)
+ }
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ b, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(b))
+ }
+ return b, nil
+}
+
+func GetNowBlock(baseURL string, apiKey string) (*Block, error) {
+ b, err := doPost(baseURL+"/wallet/getnowblock", apiKey, map[string]interface{}{})
+ if err != nil {
+ return nil, err
+ }
+ var block Block
+ return &block, json.Unmarshal(b, &block)
+}
+
+func GetBlockByNum(baseURL string, apiKey string, num int64) (*Block, error) {
+ b, err := doPost(baseURL+"/wallet/getblockbynum", apiKey, map[string]interface{}{"num": num})
+ if err != nil {
+ return nil, err
+ }
+ var block Block
+ return &block, json.Unmarshal(b, &block)
+}
+
+func parseUSDTTransfer(tx Transaction) *USDTTransfer {
+ if len(tx.RawData.Contract) == 0 {
+ return nil
+ }
+ c := tx.RawData.Contract[0]
+ if c.Type != "TriggerSmartContract" {
+ return nil
+ }
+
+ var val TriggerSmartContractValue
+ if err := json.Unmarshal(c.Parameter.Value, &val); err != nil {
+ return nil
+ }
+
+ // 检查是否是 USDT 合约
+ contractHex := strings.ToLower(strings.TrimPrefix(val.ContractAddress, "0x"))
+ if contractHex != USDTContractHex {
+ return nil
+ }
+
+ // 解析 data 字段
+ // 格式: [4字节方法ID][32字节 to 地址][32字节 amount]
+ data := strings.TrimPrefix(strings.ToLower(val.Data), "0x")
+ if len(data) < 8+64+64 {
+ return nil
+ }
+ if data[:8] != TransferMethodID {
+ return nil
+ }
+
+ // to 地址:后 40 个十六进制字符(20 字节)
+ toHex := data[8+24 : 8+64] // 跳过前12字节填充,取后20字节
+ toAddr, err := HexToTronAddress(toHex)
+ if err != nil {
+ return nil
+ }
+
+ // amount:后 32 字节大整数
+ amountHex := data[8+64 : 8+64+64]
+ amountBytes, err := hex.DecodeString(amountHex)
+ if err != nil {
+ return nil
+ }
+ amountBig := new(big.Int).SetBytes(amountBytes)
+
+ // from 地址
+ fromAddr, err := HexToTronAddress(val.OwnerAddress)
+ if err != nil {
+ fromAddr = val.OwnerAddress
+ }
+
+ // 交易状态
+ status := "SUCCESS"
+ if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
+ status = tx.Ret[0].ContractRet
+ }
+
+ return &USDTTransfer{
+ TxID: tx.TxID,
+ From: fromAddr,
+ To: toAddr,
+ Raw: amountBig,
+ Status: status,
+ }
+}
+
+func parseTRXTransfer(tx Transaction) *TRXTransfer {
+ if len(tx.RawData.Contract) == 0 {
+ return nil
+ }
+ c := tx.RawData.Contract[0]
+ if c.Type != "TransferContract" {
+ return nil
+ }
+
+ var val TransferContractValue
+ if err := json.Unmarshal(c.Parameter.Value, &val); err != nil {
+ return nil
+ }
+ if val.Amount <= 0 {
+ return nil
+ }
+
+ fromAddr, err := HexToTronAddress(val.OwnerAddress)
+ if err != nil {
+ fromAddr = val.OwnerAddress
+ }
+ toAddr, err := HexToTronAddress(val.ToAddress)
+ if err != nil {
+ toAddr = val.ToAddress
+ }
+
+ status := "SUCCESS"
+ if len(tx.Ret) > 0 && tx.Ret[0].ContractRet != "" && tx.Ret[0].ContractRet != "SUCCESS" {
+ status = tx.Ret[0].ContractRet
+ }
+
+ return &TRXTransfer{
+ TxID: tx.TxID,
+ From: fromAddr,
+ To: toAddr,
+ RawSun: val.Amount,
+ Status: status,
+ }
+}
+
+func processBlock(block *Block) {
+ blockTsMs := block.BlockHeader.RawData.Timestamp
+ for _, tx := range block.Transactions {
+ if t := parseUSDTTransfer(tx); t != nil {
+ if t.Status != "SUCCESS" {
+ continue
+ }
+ service.TryProcessTronTRC20Transfer(t.To, t.Raw, t.TxID, blockTsMs)
+ continue
+ }
+ if t := parseTRXTransfer(tx); t != nil {
+ if t.Status != "SUCCESS" {
+ continue
+ }
+ service.TryProcessTronTRXTransfer(t.To, t.RawSun, t.TxID, blockTsMs)
+ }
+ }
+}
+
+type Scanner struct {
+ baseURL string
+ apiKey string
+ lastBlock int64
+ // 统计
+ totalBlocks int64
+ totalUSDTTxs int64
+ totalTRXTxs int64
+}
+
+func NewScanner() *Scanner {
+ return &Scanner{}
+}
+
+func (s *Scanner) Init() error {
+ baseURL, apiKey, err := service.ResolveTronNode()
+ if err != nil {
+ return fmt.Errorf("resolve tron node: %w", err)
+ }
+ s.baseURL = baseURL
+ s.apiKey = apiKey
+
+ log.Sugar.Infof("[TRON-BLOCK] node=%s", s.baseURL)
+ block, err := GetNowBlock(s.baseURL, s.apiKey)
+ if err != nil {
+ return fmt.Errorf("获取初始块失败: %w", err)
+ }
+ s.lastBlock = block.BlockHeader.RawData.Number
+ log.Sugar.Infof("[TRON-BLOCK] start block=%d", s.lastBlock)
+ return nil
+}
+
+func (s *Scanner) Run() {
+ log.Sugar.Info("[TRON-BLOCK] start scanning (USDT TRC20 + TRX)")
+ ticker := time.NewTicker(PollInterval)
+ defer ticker.Stop()
+
+ statTicker := time.NewTicker(60 * time.Second)
+ defer statTicker.Stop()
+
+ for {
+ select {
+ case <-statTicker.C:
+ log.Sugar.Infof("[TRON-BLOCK] stats blocks=%d usdt=%d trx=%d", s.totalBlocks, s.totalUSDTTxs, s.totalTRXTxs)
+ case <-ticker.C:
+ s.poll()
+ }
+ }
+}
+
+func (s *Scanner) poll() {
+ latest, err := GetNowBlock(s.baseURL, s.apiKey)
+ if err != nil {
+ log.Sugar.Warnf("[TRON-BLOCK] get latest block: %v", err)
+ return
+ }
+ latestNum := latest.BlockHeader.RawData.Number
+ if latestNum <= s.lastBlock {
+ return
+ }
+
+ for num := s.lastBlock + 1; num <= latestNum; num++ {
+ var block *Block
+ if num == latestNum {
+ block = latest
+ } else {
+ block, err = GetBlockByNum(s.baseURL, s.apiKey, num)
+ if err != nil {
+ log.Sugar.Warnf("[TRON-BLOCK] get block %d: %v", num, err)
+ continue
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+ processBlock(block)
+ s.lastBlock = num
+ s.totalBlocks++
+
+ for _, tx := range block.Transactions {
+ if parseUSDTTransfer(tx) != nil {
+ s.totalUSDTTxs++
+ } else if parseTRXTransfer(tx) != nil {
+ s.totalTRXTxs++
+ }
+ }
+ }
+}
+
+func StartTronBlockScannerListener() {
+ scanner := NewScanner()
+ if err := scanner.Init(); err != nil {
+ log.Sugar.Errorf("[TRON-BLOCK] init: %v", err)
+ return
+ }
+ scanner.Run()
+}
diff --git a/src/task/listen_trc20_job.go b/src/task/listen_trc20_job.go
deleted file mode 100644
index eb8b832..0000000
--- a/src/task/listen_trc20_job.go
+++ /dev/null
@@ -1,43 +0,0 @@
-package task
-
-import (
- "sync"
-
- "github.com/GMWalletApp/epusdt/model/data"
- "github.com/GMWalletApp/epusdt/model/mdb"
- "github.com/GMWalletApp/epusdt/model/service"
- "github.com/GMWalletApp/epusdt/util/log"
-)
-
-type ListenTrc20Job struct{}
-
-var gListenTrc20JobLock sync.Mutex
-
-func (r ListenTrc20Job) Run() {
- gListenTrc20JobLock.Lock()
- defer gListenTrc20JobLock.Unlock()
- log.Sugar.Debug("[ListenTrc20Job] Job triggered")
- if !data.IsChainEnabled(mdb.NetworkTron) {
- log.Sugar.Debug("[ListenTrc20Job] chain disabled, skipping")
- return
- }
- walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
- if err != nil {
- 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.Address, &wg)
- }
- wg.Wait()
- log.Sugar.Debug("[ListenTrc20Job] Job completed")
-}
From 51a2accd9f17684fe1b0b4aeade5dd9a67ea415a Mon Sep 17 00:00:00 2001
From: Ginta <775650117@qq.com>
Date: Mon, 27 Apr 2026 15:57:26 +0800
Subject: [PATCH 6/8] :bug: fix link
---
src/model/service/task_service.go | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go
index 52ebc1b..97ce01e 100644
--- a/src/model/service/task_service.go
+++ b/src/model/service/task_service.go
@@ -13,7 +13,6 @@ import (
"github.com/GMWalletApp/epusdt/util/constant"
"github.com/GMWalletApp/epusdt/util/log"
"github.com/GMWalletApp/epusdt/util/math"
- "github.com/dromara/carbon/v2"
"github.com/ethereum/go-ethereum/common"
"github.com/shopspring/decimal"
)
From d17d212c2400beb11f24af39bb0dc1fff38370f8 Mon Sep 17 00:00:00 2001
From: line-6000 <154492442+line-6000@users.noreply.github.com>
Date: Tue, 28 Apr 2026 15:28:00 +0800
Subject: [PATCH 7/8] change tron scan logic
---
src/model/service/task_service.go | 23 ++++++++++++++++++++++-
src/task/listen.go | 13 ++++++++++++-
2 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go
index 97ce01e..6c15789 100644
--- a/src/model/service/task_service.go
+++ b/src/model/service/task_service.go
@@ -216,8 +216,29 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to
log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr)
return
}
+
+ chainTokens, err := data.ListChainTokens(chainNetwork)
+ if err != nil {
+ log.Sugar.Warnf("[%s-%s] load chain tokens: %v", net, tokenSym, err)
+ return
+ }
+ var tokenConfig *mdb.ChainToken
+ for _, t := range chainTokens {
+ if strings.EqualFold(t.Symbol, tokenSym) {
+ tokenConfig = &t
+ break
+ }
+ }
+ if tokenConfig == nil || !tokenConfig.Enabled {
+ log.Sugar.Warnf("[%s-%s] token not enabled or configured in chain_tokens", net, tokenSym)
+ return
+ }
+
+ pow := decimal.New(1, int32(tokenConfig.Decimals))
+ log.Sugar.Warnf("tokenConfig.Decimals %d pow %s", tokenConfig.Decimals, pow.String())
+
decimalQuant := decimal.NewFromBigInt(rawValue, 0)
- amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
+ amount := math.MustParsePrecFloat64(decimalQuant.Div(pow).InexactFloat64(), 2)
if amount <= 0 {
log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount)
return
diff --git a/src/task/listen.go b/src/task/listen.go
index 36b4a05..96d14e7 100644
--- a/src/task/listen.go
+++ b/src/task/listen.go
@@ -16,13 +16,24 @@ func Start() {
go StartTronBlockScannerListener()
c := cron.New()
+
+ // Solana polling
+ _, 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)")
+
// RPC node health checks
- _, err := c.AddJob("@every 30s", RpcHealthJob{})
+ _, err = c.AddJob("@every 30s", RpcHealthJob{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add RpcHealthJob: %v", err)
return
}
log.Sugar.Info("[task] RpcHealthJob scheduled successfully (@every 30s)")
+
c.Start()
log.Sugar.Info("[task] Task scheduler started")
}
From 06933451c59cba42bbdf5e6adc67f5ffac69c954 Mon Sep 17 00:00:00 2001
From: Ginta <775650117@qq.com>
Date: Tue, 28 Apr 2026 16:28:01 +0800
Subject: [PATCH 8/8] actions
---
.github/workflows/docker-alpine.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/docker-alpine.yml b/.github/workflows/docker-alpine.yml
index de834e5..8767799 100644
--- a/.github/workflows/docker-alpine.yml
+++ b/.github/workflows/docker-alpine.yml
@@ -33,7 +33,7 @@ jobs:
images: ${{ env.DOCKERHUB_NAMESPACE }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=alpine
- type=raw,value=latest,enable={{is_default_branch}}
+ type=raw,value=latest
type=ref,event=branch,suffix=-alpine
type=ref,event=tag
type=sha,prefix=sha-,suffix=-alpine