diff --git a/src/controller/comm/supported_asset_controller.go b/src/controller/comm/supported_asset_controller.go new file mode 100644 index 0000000..0a47208 --- /dev/null +++ b/src/controller/comm/supported_asset_controller.go @@ -0,0 +1,135 @@ +package comm + +import ( + "sort" + "strconv" + + "github.com/assimon/luuu/model/data" + "github.com/assimon/luuu/model/response" + "github.com/assimon/luuu/util/constant" + "github.com/labstack/echo/v4" +) + +type addSupportedAssetRequest struct { + Network string `json:"network" validate:"required"` + Token string `json:"token" validate:"required"` + Status int64 `json:"status" validate:"required|in:1,2"` +} + +type updateSupportedAssetRequest struct { + Network string `json:"network" validate:"required"` + Token string `json:"token" validate:"required"` + Status int64 `json:"status" validate:"required|in:1,2"` +} + +// GetSupportedAssets 对外公开可用链与 token 列表(无需鉴权,仅返回已启用项)。 +func (c *BaseCommController) GetSupportedAssets(ctx echo.Context) error { + list, err := data.ListEnabledSupportedAssets() + if err != nil { + return c.FailJson(ctx, err) + } + + grouped := make(map[string][]string) + for _, item := range list { + grouped[item.Network] = append(grouped[item.Network], item.Token) + } + + networks := make([]string, 0, len(grouped)) + for network := range grouped { + networks = append(networks, network) + } + sort.Strings(networks) + + supports := make([]response.NetworkTokenSupport, 0, len(networks)) + for _, network := range networks { + tokens := grouped[network] + sort.Strings(tokens) + supports = append(supports, response.NetworkTokenSupport{ + Network: network, + Tokens: tokens, + }) + } + + return c.SucJson(ctx, response.SupportedAssetsResponse{Supports: supports}) +} + +// ListSupportedAssetRecords 查询支持项明细(无需鉴权)。 +func (c *BaseCommController) ListSupportedAssetRecords(ctx echo.Context) error { + network := ctx.QueryParam("network") + list, err := data.ListSupportedAssets(network) + if err != nil { + return c.FailJson(ctx, err) + } + return c.SucJson(ctx, list) +} + +// GetSupportedAsset 查询单条支持项(无需鉴权)。 +func (c *BaseCommController) GetSupportedAsset(ctx echo.Context) error { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + asset, err := data.GetSupportedAssetByID(id) + if err != nil { + return c.FailJson(ctx, err) + } + if asset.ID <= 0 { + return c.FailJson(ctx, constant.SupportedAssetNotFound) + } + return c.SucJson(ctx, asset) +} + +// AddSupportedAsset 新增支持项(鉴权)。 +func (c *BaseCommController) AddSupportedAsset(ctx echo.Context) error { + req := new(addSupportedAssetRequest) + if err := ctx.Bind(req); err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + if err := c.ValidateStruct(ctx, req); err != nil { + return c.FailJson(ctx, err) + } + asset, err := data.AddSupportedAsset(req.Network, req.Token, req.Status) + if err != nil { + return c.FailJson(ctx, err) + } + return c.SucJson(ctx, asset) +} + +// UpdateSupportedAsset 修改支持项(鉴权)。 +func (c *BaseCommController) UpdateSupportedAsset(ctx echo.Context) error { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + req := new(updateSupportedAssetRequest) + if err := ctx.Bind(req); err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + if err := c.ValidateStruct(ctx, req); err != nil { + return c.FailJson(ctx, err) + } + asset, err := data.UpdateSupportedAsset(id, req.Network, req.Token, req.Status) + if err != nil { + return c.FailJson(ctx, err) + } + return c.SucJson(ctx, asset) +} + +// DeleteSupportedAsset 删除支持项(鉴权)。 +func (c *BaseCommController) DeleteSupportedAsset(ctx echo.Context) error { + id, err := strconv.ParseUint(ctx.Param("id"), 10, 64) + if err != nil { + return c.FailJson(ctx, constant.ParamsMarshalErr) + } + asset, err := data.GetSupportedAssetByID(id) + if err != nil { + return c.FailJson(ctx, err) + } + if asset.ID <= 0 { + return c.FailJson(ctx, constant.SupportedAssetNotFound) + } + if err := data.DeleteSupportedAssetByID(id); err != nil { + return c.FailJson(ctx, err) + } + return c.SucJson(ctx, nil) +} diff --git a/src/model/dao/mdb_table_init.go b/src/model/dao/mdb_table_init.go index 1082b88..c63bdec 100644 --- a/src/model/dao/mdb_table_init.go +++ b/src/model/dao/mdb_table_init.go @@ -22,5 +22,39 @@ func MdbTableInit() { // panic(err) return } + if err := Mdb.AutoMigrate(&mdb.SupportedAsset{}); err != nil { + color.Red.Printf("[store_db] AutoMigrate DB(SupportedAsset),err=%s\n", err) + return + } + + seedSupportedAssets() }) } + +func seedSupportedAssets() { + var cnt int64 + if err := Mdb.Model(&mdb.SupportedAsset{}).Count(&cnt).Error; err != nil { + color.Red.Printf("[store_db] Count SupportedAsset,err=%s\n", err) + return + } + if cnt > 0 { + return + } + defaults := []mdb.SupportedAsset{ + {Network: mdb.NetworkTron, Token: "TRX", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkTron, Token: "USDT", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkSolana, Token: "SOL", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkSolana, Token: "USDT", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkSolana, Token: "USDC", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkEthereum, Token: "USDT", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkEthereum, Token: "USDC", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkBsc, Token: "USDT", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkBsc, Token: "USDC", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkPolygon, Token: "USDT", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkPolygon, Token: "USDC", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkPlasma, Token: "USDT", Status: mdb.TokenStatusEnable}, + } + if err := Mdb.Create(&defaults).Error; err != nil { + color.Red.Printf("[store_db] Seed SupportedAsset,err=%s\n", err) + } +} diff --git a/src/model/data/supported_asset_data.go b/src/model/data/supported_asset_data.go new file mode 100644 index 0000000..5738662 --- /dev/null +++ b/src/model/data/supported_asset_data.go @@ -0,0 +1,99 @@ +package data + +import ( + "strings" + + "github.com/assimon/luuu/model/dao" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/util/constant" +) + +func normalizeNetworkToken(network, token string) (string, string) { + return strings.ToLower(strings.TrimSpace(network)), strings.ToUpper(strings.TrimSpace(token)) +} + +func AddSupportedAsset(network, token string, status int64) (*mdb.SupportedAsset, error) { + network, token = normalizeNetworkToken(network, token) + exist, err := GetSupportedAssetByNetworkAndToken(network, token) + if err != nil { + return nil, err + } + if exist.ID > 0 { + return nil, constant.SupportedAssetAlreadyExists + } + asset := &mdb.SupportedAsset{ + Network: network, + Token: token, + Status: status, + } + err = dao.Mdb.Create(asset).Error + return asset, err +} + +func GetSupportedAssetByNetworkAndToken(network, token string) (*mdb.SupportedAsset, error) { + network, token = normalizeNetworkToken(network, token) + asset := new(mdb.SupportedAsset) + err := dao.Mdb.Model(asset). + Where("network = ?", network). + Where("token = ?", token). + Limit(1).Find(asset).Error + return asset, err +} + +func GetSupportedAssetByID(id uint64) (*mdb.SupportedAsset, error) { + asset := new(mdb.SupportedAsset) + err := dao.Mdb.Model(asset).Limit(1).Find(asset, id).Error + return asset, err +} + +func UpdateSupportedAsset(id uint64, network, token string, status int64) (*mdb.SupportedAsset, error) { + network, token = normalizeNetworkToken(network, token) + + asset, err := GetSupportedAssetByID(id) + if err != nil { + return nil, err + } + if asset.ID <= 0 { + return nil, constant.SupportedAssetNotFound + } + + exist, err := GetSupportedAssetByNetworkAndToken(network, token) + if err != nil { + return nil, err + } + if exist.ID > 0 && exist.ID != id { + return nil, constant.SupportedAssetAlreadyExists + } + + asset.Network = network + asset.Token = token + asset.Status = status + if err := dao.Mdb.Save(asset).Error; err != nil { + return nil, err + } + return asset, nil +} + +func DeleteSupportedAssetByID(id uint64) error { + return dao.Mdb.Where("id = ?", id).Delete(&mdb.SupportedAsset{}).Error +} + +func ListSupportedAssets(network string) ([]mdb.SupportedAsset, error) { + network = strings.ToLower(strings.TrimSpace(network)) + var list []mdb.SupportedAsset + tx := dao.Mdb.Model(&mdb.SupportedAsset{}) + if network != "" { + tx = tx.Where("network = ?", network) + } + err := tx.Order("network asc, token asc").Find(&list).Error + return list, err +} + +func ListEnabledSupportedAssets() ([]mdb.SupportedAsset, error) { + var list []mdb.SupportedAsset + err := dao.Mdb.Model(&mdb.SupportedAsset{}). + Where("status = ?", mdb.TokenStatusEnable). + Order("network asc, token asc"). + Find(&list).Error + return list, err +} diff --git a/src/model/mdb/supported_asset_mdb.go b/src/model/mdb/supported_asset_mdb.go new file mode 100644 index 0000000..1698a67 --- /dev/null +++ b/src/model/mdb/supported_asset_mdb.go @@ -0,0 +1,12 @@ +package mdb + +type SupportedAsset struct { + Network string `gorm:"column:network;uniqueIndex:supported_asset_network_token_uindex,priority:1" json:"network"` + Token string `gorm:"column:token;uniqueIndex:supported_asset_network_token_uindex,priority:2" json:"token"` + Status int64 `gorm:"column:status;default:1" json:"status"` + BaseModel +} + +func (s *SupportedAsset) TableName() string { + return "supported_asset" +} diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go index cdbb194..c9a7dc1 100644 --- a/src/model/mdb/wallet_address_mdb.go +++ b/src/model/mdb/wallet_address_mdb.go @@ -9,6 +9,9 @@ const ( NetworkTron = "tron" NetworkSolana = "solana" NetworkEthereum = "ethereum" + NetworkBsc = "bsc" + NetworkPolygon = "polygon" + NetworkPlasma = "plasma" ) type WalletAddress struct { diff --git a/src/model/response/support_response.go b/src/model/response/support_response.go new file mode 100644 index 0000000..b78855d --- /dev/null +++ b/src/model/response/support_response.go @@ -0,0 +1,10 @@ +package response + +type NetworkTokenSupport struct { + Network string `json:"network"` + Tokens []string `json:"tokens"` +} + +type SupportedAssetsResponse struct { + Supports []NetworkTokenSupport `json:"supports"` +} diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go index a666513..131da67 100644 --- a/src/model/service/task_service.go +++ b/src/model/service/task_service.go @@ -258,66 +258,104 @@ func checkTrc20Transfers(address string, wg *sync.WaitGroup) { } } -func TryProcessEthereumERC20Transfer(contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) { +func evmChainLogLabel(chainNetwork string) string { + switch chainNetwork { + case mdb.NetworkEthereum: + return "ETH" + case mdb.NetworkBsc: + return "BSC" + case mdb.NetworkPolygon: + return "POLYGON" + case mdb.NetworkPlasma: + return "PLASMA" + default: + return "EVM" + } +} + +// TryProcessEvmERC20Transfer 处理各 EVM 链上 USDT/USDC(及 Polygon USDC.e)的 Transfer 入账(合约与 network 需一致)。 +func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) { defer func() { if err := recover(); err != nil { - log.Sugar.Errorf("[ETH-WS] TryProcessEthereumERC20Transfer panic: %v", err) + log.Sugar.Errorf("[%s-WS] TryProcessEvmERC20Transfer panic: %v", evmChainLogLabel(chainNetwork), err) } }() - usdt := common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7") - usdc := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") - var tokenSym string - switch contract { - case usdt: - tokenSym = "USDT" - case usdc: - tokenSym = "USDC" + 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: - log.Sugar.Warnf("[ETH-WS] skip unsupported contract %s", contract.Hex()) 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) walletAddr := strings.ToLower(toAddr.Hex()) if rawValue == nil || rawValue.Sign() <= 0 { - log.Sugar.Infof("[ETH-%s][%s] skip non-positive or nil amount", tokenSym, walletAddr) + log.Sugar.Infof("[%s-%s][%s] skip non-positive or nil amount", net, tokenSym, walletAddr) return } decimalQuant := decimal.NewFromBigInt(rawValue, 0) amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2) if amount <= 0 { - log.Sugar.Warnf("[ETH-%s][%s] skip non-positive amount %.2f", tokenSym, walletAddr, amount) + log.Sugar.Warnf("[%s-%s][%s] skip non-positive amount %.2f", net, tokenSym, walletAddr, amount) return } - tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(mdb.NetworkEthereum, walletAddr, tokenSym, amount) + log.Sugar.Debugf("[%s-%s][%s] processing transfer hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount) + + tradeID, err := data.GetTradeIdByWalletAddressAndAmountAndToken(chainNetwork, walletAddr, tokenSym, amount) if err != nil { - log.Sugar.Warnf("[ETH-%s][%s] lock lookup: %v", tokenSym, walletAddr, err) + log.Sugar.Warnf("[%s-%s][%s] lock lookup: %v", net, tokenSym, walletAddr, err) return } if tradeID == "" { - log.Sugar.Warnf("[ETH-%s][%s] skip unmatched tx hash=%s amount=%.2f", tokenSym, walletAddr, txHash, amount) + log.Sugar.Warnf("[%s-%s][%s] skip unmatched tx hash=%s amount=%.2f", net, tokenSym, walletAddr, txHash, amount) return } order, err := data.GetOrderInfoByTradeId(tradeID) if err != nil { - log.Sugar.Warnf("[ETH-%s][%s] load order: %v", tokenSym, walletAddr, err) + log.Sugar.Warnf("[%s-%s][%s] load order: %v", net, tokenSym, walletAddr, err) return } - if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkEthereum { - log.Sugar.Warnf("[ETH-%s][%s] skip trade_id=%s network=%q", tokenSym, walletAddr, tradeID, order.Network) + if strings.ToLower(strings.TrimSpace(order.Network)) != chainNetwork { + log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s network=%q", net, tokenSym, walletAddr, tradeID, order.Network) return } if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym { - log.Sugar.Warnf("[ETH-%s][%s] skip trade_id=%s token mismatch order=%s", tokenSym, walletAddr, tradeID, order.Token) + log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token) return } req := &request.OrderProcessingRequest{ ReceiveAddress: walletAddr, Token: tokenSym, - Network: mdb.NetworkEthereum, + Network: chainNetwork, TradeId: tradeID, Amount: amount, BlockTransactionId: txHash, @@ -325,15 +363,15 @@ func TryProcessEthereumERC20Transfer(contract common.Address, toAddr common.Addr err = OrderProcessing(req) if err != nil { if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { - log.Sugar.Infof("[ETH-%s][%s] skip resolved trade_id=%s hash=%s err=%v", tokenSym, walletAddr, tradeID, txHash, err) + log.Sugar.Infof("[%s-%s][%s] skip resolved trade_id=%s hash=%s err=%v", net, tokenSym, walletAddr, tradeID, txHash, err) return } - log.Sugar.Errorf("[ETH-%s][%s] OrderProcessing: %v", tokenSym, walletAddr, err) + log.Sugar.Errorf("[%s-%s][%s] OrderProcessing: %v", net, tokenSym, walletAddr, err) return } sendPaymentNotification(order) - log.Sugar.Infof("[ETH-%s][%s] payment processed trade_id=%s hash=%s", tokenSym, walletAddr, tradeID, txHash) + log.Sugar.Infof("[%s-%s][%s] payment processed trade_id=%s hash=%s", net, tokenSym, walletAddr, tradeID, txHash) } func sendPaymentNotification(order *mdb.Orders) { @@ -372,6 +410,12 @@ func networkDisplay(n string) string { return "Solana" case mdb.NetworkEthereum: return "Ethereum" + case mdb.NetworkBsc: + return "BSC" + case mdb.NetworkPolygon: + return "Polygon" + case mdb.NetworkPlasma: + return "Plasma" default: if n == "" { return "Tron" diff --git a/src/route/router.go b/src/route/router.go index 90114ff..bad3e50 100644 --- a/src/route/router.go +++ b/src/route/router.go @@ -64,6 +64,12 @@ func RegisterRoute(e *echo.Echo) { // gmpay v1 routes gmpayV1 := paymentRoute.Group("/gmpay/v1") gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign()) + gmpayV1.GET("/supported-assets", comm.Ctrl.GetSupportedAssets) + gmpayV1.GET("/supported-assets/records", comm.Ctrl.ListSupportedAssetRecords) + gmpayV1.GET("/supported-assets/:id", comm.Ctrl.GetSupportedAsset) + gmpayV1.POST("/supported-assets/add", comm.Ctrl.AddSupportedAsset, middleware.CheckApiToken()) + gmpayV1.POST("/supported-assets/:id/update", comm.Ctrl.UpdateSupportedAsset, middleware.CheckApiToken()) + gmpayV1.POST("/supported-assets/:id/delete", comm.Ctrl.DeleteSupportedAsset, middleware.CheckApiToken()) // wallet management routes walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken()) diff --git a/src/route/router_test.go b/src/route/router_test.go index ddc0a5c..b6ee09c 100644 --- a/src/route/router_test.go +++ b/src/route/router_test.go @@ -52,11 +52,30 @@ func setupTestEnv(t *testing.T) *echo.Echo { } // ensure tables exist (MdbTableInit uses sync.Once, so migrate directly) - dao.Mdb.AutoMigrate(&mdb.Orders{}, &mdb.WalletAddress{}) + dao.Mdb.AutoMigrate(&mdb.Orders{}, &mdb.WalletAddress{}, &mdb.SupportedAsset{}) // seed wallet addresses dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable}) dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkSolana, Address: "SolTestAddress001", Status: mdb.TokenStatusEnable}) + // seed supported assets if empty + var supportCnt int64 + dao.Mdb.Model(&mdb.SupportedAsset{}).Count(&supportCnt) + if supportCnt == 0 { + dao.Mdb.Create(&[]mdb.SupportedAsset{ + {Network: mdb.NetworkTron, Token: "TRX", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkTron, Token: "USDT", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkSolana, Token: "SOL", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkSolana, Token: "USDT", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkSolana, Token: "USDC", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkEthereum, Token: "USDT", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkEthereum, Token: "USDC", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkBsc, Token: "USDT", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkBsc, Token: "USDC", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkPolygon, Token: "USDT", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkPolygon, Token: "USDC", Status: mdb.TokenStatusEnable}, + {Network: mdb.NetworkPlasma, Token: "USDT", Status: mdb.TokenStatusEnable}, + }) + } e := echo.New() RegisterRoute(e) @@ -207,6 +226,104 @@ func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interfac return resp } +func TestGetSupportedAssetsPublic(t *testing.T) { + e := setupTestEnv(t) + + req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d, body=%s", rec.Code, rec.Body.String()) + } + + resp := parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("expected status_code=200, got %v", resp["status_code"]) + } + + data, ok := resp["data"].(map[string]interface{}) + if !ok { + t.Fatalf("expected object data, got %T", resp["data"]) + } + supports, ok := data["supports"].([]interface{}) + if !ok { + t.Fatalf("expected supports array, got %T", data["supports"]) + } + if len(supports) < 6 { + t.Fatalf("expected >= 6 network supports, got %d", len(supports)) + } + + seen := map[string]bool{} + for _, item := range supports { + row := item.(map[string]interface{}) + network := row["network"].(string) + seen[network] = true + } + for _, n := range []string{"tron", "solana", "eth", "bsc", "polygon", "plasma"} { + if !seen[n] { + t.Fatalf("missing network support: %s", n) + } + } +} + +func TestSupportedAssetCRUD(t *testing.T) { + e := setupTestEnv(t) + + // public query list (no auth) + req := httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets/records", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + resp := parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("public list failed: %v", resp) + } + + // create requires auth + rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/add", map[string]interface{}{ + "network": "arb", + "token": "usdt", + "status": 1, + }) + resp = parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("add supported asset failed: %v", resp) + } + created := resp["data"].(map[string]interface{}) + assetID := fmt.Sprintf("%.0f", created["id"].(float64)) + + // get by id is public + req = httptest.NewRequest(http.MethodGet, "/payments/gmpay/v1/supported-assets/"+assetID, nil) + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + resp = parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("public get by id failed: %v", resp) + } + + // update requires auth + rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/"+assetID+"/update", map[string]interface{}{ + "network": "arbitrum", + "token": "usdc", + "status": 1, + }) + resp = parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("update supported asset failed: %v", resp) + } + updated := resp["data"].(map[string]interface{}) + if updated["network"] != "arbitrum" || updated["token"] != "USDC" { + t.Fatalf("unexpected updated data: %v", updated) + } + + // delete requires auth + rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/"+assetID+"/delete", nil) + resp = parseResp(t, rec) + if resp["status_code"].(float64) != 200 { + t.Fatalf("delete supported asset failed: %v", resp) + } +} + // TestWalletAddAndList tests adding wallets via API and listing them. func TestWalletAddAndList(t *testing.T) { e := setupTestEnv(t) diff --git a/src/task/listen.go b/src/task/listen.go index 72121d5..67ccf21 100644 --- a/src/task/listen.go +++ b/src/task/listen.go @@ -8,6 +8,9 @@ import ( func Start() { log.Sugar.Info("[task] Starting task scheduler...") go StartEthereumWebSocketListener() + go StartBscWebSocketListener() + //go StartPolygonWebSocketListener() + //go StartPlasmaWebSocketListener() c := cron.New() // trc20钱包监听 diff --git a/src/task/listen_bsc.go b/src/task/listen_bsc.go new file mode 100644 index 0000000..6cae2f7 --- /dev/null +++ b/src/task/listen_bsc.go @@ -0,0 +1,112 @@ +package task + +import ( + "context" + "math/big" + "strings" + "sync/atomic" + "time" + + "github.com/assimon/luuu/model/data" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/model/service" + "github.com/assimon/luuu/util/log" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +var ( + // USDT / USDC 合约地址(BSC 主网) + bscUsdtContract = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955") + bscUsdcContract = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d") +) + +type bscRecipientSnapshot struct { + addrs map[string]struct{} +} + +var bscWatchedRecipients atomic.Pointer[bscRecipientSnapshot] + +func StartBscWebSocketListener() { + wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkBsc) + if err != nil { + log.Sugar.Errorf("[BSC-WS] Failed to get wallet addresses: %v", err) + return + } + storeBscRecipientsFromWallets(wallets) + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for range ticker.C { + w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkBsc) + if err != nil { + log.Sugar.Warnf("[BSC-WS] refresh wallet addresses: %v", err) + continue + } + storeBscRecipientsFromWallets(w) + } + }() + wsURL := "wss://bsc.publicnode.com" + query := ethereum.FilterQuery{ + Addresses: []common.Address{ + bscUsdtContract, + bscUsdcContract, + }, + Topics: [][]common.Hash{}, + } + + runEvmWsLogListener("[BSC-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { + if len(vLog.Topics) < 3 { + return + } + + event := vLog.Topics[0].String() + if event != transferEventHash.String() { + return + } + + amount := new(big.Int).SetBytes(vLog.Data) + + toAddr := common.HexToAddress(vLog.Topics[2].Hex()) + + if !isWatchedBscRecipient(toAddr) { + return + } + + var blockTsMs int64 + header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber))) + if err != nil { + log.Sugar.Warnf("[BSC-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err) + blockTsMs = time.Now().UnixMilli() + } else { + blockTsMs = int64(header.Time) * 1000 + } + + service.TryProcessEvmERC20Transfer(mdb.NetworkBsc, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs) + }) +} + +func storeBscRecipientsFromWallets(wallets []mdb.WalletAddress) int { + m := make(map[string]struct{}) + for _, w := range wallets { + a := strings.TrimSpace(w.Address) + if !common.IsHexAddress(a) { + continue + } + m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{} + } + bscWatchedRecipients.Store(&bscRecipientSnapshot{addrs: m}) + return len(m) +} + +func isWatchedBscRecipient(to common.Address) bool { + snap := bscWatchedRecipients.Load() + if snap == nil || len(snap.addrs) == 0 { + return false + } + _, ok := snap.addrs[strings.ToLower(to.Hex())] + return ok +} diff --git a/src/task/listen_eth.go b/src/task/listen_eth.go index 127a6b8..d04bada 100644 --- a/src/task/listen_eth.go +++ b/src/task/listen_eth.go @@ -2,7 +2,6 @@ package task import ( "context" - "fmt" "math/big" "strings" "sync/atomic" @@ -38,7 +37,7 @@ var ethWatchedRecipients atomic.Pointer[ethRecipientSnapshot] func StartEthereumWebSocketListener() { wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum) if err != nil { - log.Sugar.Fatalf("Failed to get wallet addresses: %v", err) + log.Sugar.Errorf("Failed to get wallet addresses: %v", err) return } StoreEthRecipientsFromWallets(wallets) @@ -55,16 +54,6 @@ func StartEthereumWebSocketListener() { } }() wsURL := "wss://ethereum.publicnode.com" - - client, err := ethclient.Dial(wsURL) - if err != nil { - log.Sugar.Fatal("连接失败:", err) - } - - // 创建日志通道 - logsCh := make(chan types.Log) - - // 订阅条件 query := ethereum.FilterQuery{ Addresses: []common.Address{ usdtContract, @@ -73,59 +62,35 @@ func StartEthereumWebSocketListener() { Topics: [][]common.Hash{}, } - // 订阅日志(核心) - sub, err := client.SubscribeFilterLogs(context.Background(), query, logsCh) - if err != nil { - log.Sugar.Fatal("订阅失败:", err) - } - - fmt.Println("🚀 开始监听 USDT / USDC 收款...") - - for { - select { - case err := <-sub.Err(): - log.Sugar.Fatal("订阅错误:", err) - - case vLog := <-logsCh: - - // topics: - // [0] Transfer event - // [1] from - // [2] to - if len(vLog.Topics) < 3 { - continue - } - - event := vLog.Topics[0].String() - if event != transferEventHash.String() { - continue - } - - // value from data - amount := new(big.Int).SetBytes(vLog.Data) - - - if event != transferEventHash.String() { - continue - } - toAddr := common.HexToAddress(vLog.Topics[2].Hex()) - - if !isWatchedEthRecipient(toAddr) { - continue - } - - var blockTsMs int64 - header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber))) - if err != nil { - log.Sugar.Warnf("[ETH-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err) - blockTsMs = time.Now().UnixMilli() - } else { - blockTsMs = int64(header.Time) * 1000 - } - - service.TryProcessEthereumERC20Transfer(vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs) + runEvmWsLogListener("[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { + if len(vLog.Topics) < 3 { + return } - } + + event := vLog.Topics[0].String() + if event != transferEventHash.String() { + return + } + + amount := new(big.Int).SetBytes(vLog.Data) + + toAddr := common.HexToAddress(vLog.Topics[2].Hex()) + + if !isWatchedEthRecipient(toAddr) { + return + } + + var blockTsMs int64 + header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber))) + if err != nil { + log.Sugar.Warnf("[ETH-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err) + blockTsMs = time.Now().UnixMilli() + } else { + blockTsMs = int64(header.Time) * 1000 + } + + service.TryProcessEvmERC20Transfer(mdb.NetworkEthereum, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs) + }) } func StoreEthRecipientsFromWallets(wallets []mdb.WalletAddress) int { diff --git a/src/task/listen_evm_ws.go b/src/task/listen_evm_ws.go new file mode 100644 index 0000000..087b1f7 --- /dev/null +++ b/src/task/listen_evm_ws.go @@ -0,0 +1,81 @@ +package task + +import ( + "context" + "time" + + "github.com/assimon/luuu/util/log" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +func runEvmWsLogListener(logPrefix, wsURL string, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) { + const ( + minBackoff = 2 * time.Second + maxBackoff = 60 * time.Second + rejoinWait = 3 * time.Second + ) + failWait := minBackoff + + for { + client, err := ethclient.Dial(wsURL) + if err != nil { + log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait) + time.Sleep(failWait) + failWait = nextBackoff(failWait, maxBackoff) + continue + } + + logsCh := make(chan types.Log) + sub, err := client.SubscribeFilterLogs(context.Background(), query, logsCh) + if err != nil { + client.Close() + log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait) + time.Sleep(failWait) + failWait = nextBackoff(failWait, maxBackoff) + continue + } + failWait = minBackoff + + log.Sugar.Infof("%s connected, subscribed to USDT/USDC Transfer logs", logPrefix) + + recvLoop(client, sub, logsCh, logPrefix, handleLog) + + time.Sleep(rejoinWait) + } +} + +func recvLoop(client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, logPrefix string, handleLog func(*ethclient.Client, types.Log)) { + defer func() { + sub.Unsubscribe() + client.Close() + }() + + for { + select { + case err := <-sub.Err(): + if err != nil { + log.Sugar.Warnf("%s subscription error: %v, reconnecting", logPrefix, err) + } else { + log.Sugar.Warnf("%s subscription closed, reconnecting", logPrefix) + } + return + case vLog, ok := <-logsCh: + if !ok { + log.Sugar.Warnf("%s log channel closed, reconnecting", logPrefix) + return + } + handleLog(client, vLog) + } + } +} + +func nextBackoff(cur, max time.Duration) time.Duration { + n := cur * 2 + if n > max { + return max + } + return n +} diff --git a/src/task/listen_plasma.go b/src/task/listen_plasma.go new file mode 100644 index 0000000..9b44703 --- /dev/null +++ b/src/task/listen_plasma.go @@ -0,0 +1,108 @@ +package task + +import ( + "context" + "math/big" + "strings" + "sync/atomic" + "time" + + "github.com/assimon/luuu/model/data" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/model/service" + "github.com/assimon/luuu/util/log" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +var ( + plasmaUsdt0Contract = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb") +) + +type plasmaRecipientSnapshot struct { + addrs map[string]struct{} +} + +var plasmaWatchedRecipients atomic.Pointer[plasmaRecipientSnapshot] + +func StartPlasmaWebSocketListener() { + wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPlasma) + if err != nil { + log.Sugar.Errorf("[PLASMA-WS] Failed to get wallet addresses: %v", err) + return + } + storePlasmaRecipientsFromWallets(wallets) + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for range ticker.C { + w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPlasma) + if err != nil { + log.Sugar.Warnf("[PLASMA-WS] refresh wallet addresses: %v", err) + continue + } + storePlasmaRecipientsFromWallets(w) + } + }() + // 文档提供 https://rpc.plasma.to;常见实现同主机 wss + wsURL := "wss://rpc.plasma.to" + query := ethereum.FilterQuery{ + Addresses: []common.Address{plasmaUsdt0Contract}, + Topics: [][]common.Hash{}, + } + + runEvmWsLogListener("[PLASMA-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { + if len(vLog.Topics) < 3 { + return + } + + event := vLog.Topics[0].String() + if event != transferEventHash.String() { + return + } + + amount := new(big.Int).SetBytes(vLog.Data) + + toAddr := common.HexToAddress(vLog.Topics[2].Hex()) + + if !isWatchedPlasmaRecipient(toAddr) { + return + } + + var blockTsMs int64 + header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber))) + if err != nil { + log.Sugar.Warnf("[PLASMA-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err) + blockTsMs = time.Now().UnixMilli() + } else { + blockTsMs = int64(header.Time) * 1000 + } + + service.TryProcessEvmERC20Transfer(mdb.NetworkPlasma, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs) + }) +} + +func storePlasmaRecipientsFromWallets(wallets []mdb.WalletAddress) int { + m := make(map[string]struct{}) + for _, w := range wallets { + a := strings.TrimSpace(w.Address) + if !common.IsHexAddress(a) { + continue + } + m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{} + } + plasmaWatchedRecipients.Store(&plasmaRecipientSnapshot{addrs: m}) + return len(m) +} + +func isWatchedPlasmaRecipient(to common.Address) bool { + snap := plasmaWatchedRecipients.Load() + if snap == nil || len(snap.addrs) == 0 { + return false + } + _, ok := snap.addrs[strings.ToLower(to.Hex())] + return ok +} diff --git a/src/task/listen_polygon.go b/src/task/listen_polygon.go new file mode 100644 index 0000000..9734e4d --- /dev/null +++ b/src/task/listen_polygon.go @@ -0,0 +1,114 @@ +package task + +import ( + "context" + "math/big" + "strings" + "sync/atomic" + "time" + + "github.com/assimon/luuu/model/data" + "github.com/assimon/luuu/model/mdb" + "github.com/assimon/luuu/model/service" + "github.com/assimon/luuu/util/log" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" +) + +var ( + // Polygon PoS 主网 USDT / USDC(原生)/ USDC.e(桥接) + polygonUsdtContract = common.HexToAddress("0xc2132D05D31c914a87C6611C10748AEb04B58e8F") + polygonUsdcContract = common.HexToAddress("0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359") + polygonUsdcEContract = common.HexToAddress("0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174") +) + +type polygonRecipientSnapshot struct { + addrs map[string]struct{} +} + +var polygonWatchedRecipients atomic.Pointer[polygonRecipientSnapshot] + +func StartPolygonWebSocketListener() { + wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPolygon) + if err != nil { + log.Sugar.Errorf("[POLYGON-WS] Failed to get wallet addresses: %v", err) + return + } + storePolygonRecipientsFromWallets(wallets) + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for range ticker.C { + w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPolygon) + if err != nil { + log.Sugar.Warnf("[POLYGON-WS] refresh wallet addresses: %v", err) + continue + } + storePolygonRecipientsFromWallets(w) + } + }() + wsURL := "wss://polygon-bor-ws.publicnode.com" + query := ethereum.FilterQuery{ + Addresses: []common.Address{ + polygonUsdtContract, + polygonUsdcContract, + polygonUsdcEContract, + }, + Topics: [][]common.Hash{}, + } + + runEvmWsLogListener("[POLYGON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) { + if len(vLog.Topics) < 3 { + return + } + + event := vLog.Topics[0].String() + if event != transferEventHash.String() { + return + } + + amount := new(big.Int).SetBytes(vLog.Data) + + toAddr := common.HexToAddress(vLog.Topics[2].Hex()) + + if !isWatchedPolygonRecipient(toAddr) { + return + } + + var blockTsMs int64 + header, err := client.HeaderByNumber(context.Background(), big.NewInt(int64(vLog.BlockNumber))) + if err != nil { + log.Sugar.Warnf("[POLYGON-WS] HeaderByNumber block=%d: %v, using local time", vLog.BlockNumber, err) + blockTsMs = time.Now().UnixMilli() + } else { + blockTsMs = int64(header.Time) * 1000 + } + + service.TryProcessEvmERC20Transfer(mdb.NetworkPolygon, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs) + }) +} + +func storePolygonRecipientsFromWallets(wallets []mdb.WalletAddress) int { + m := make(map[string]struct{}) + for _, w := range wallets { + a := strings.TrimSpace(w.Address) + if !common.IsHexAddress(a) { + continue + } + m[strings.ToLower(common.HexToAddress(a).Hex())] = struct{}{} + } + polygonWatchedRecipients.Store(&polygonRecipientSnapshot{addrs: m}) + return len(m) +} + +func isWatchedPolygonRecipient(to common.Address) bool { + snap := polygonWatchedRecipients.Load() + if snap == nil || len(snap.addrs) == 0 { + return false + } + _, ok := snap.addrs[strings.ToLower(to.Hex())] + return ok +} diff --git a/src/telegram/handle.go b/src/telegram/handle.go index 24ef2cb..95522df 100644 --- a/src/telegram/handle.go +++ b/src/telegram/handle.go @@ -2,6 +2,7 @@ package telegram import ( "fmt" + "sort" "strings" "sync" "time" @@ -14,12 +15,14 @@ import ( ) const ( - ReplayAddWallet = "请发给我 TRON(T 开头)、以太坊主网(0x 开头)或 Solana 收款地址" + ReplaySelectNetwork = "请选择要添加的钱包网络" + ReplayAddWallet = "请发送 %s 网络收款地址" pendingWalletAddressTTL = 5 * time.Minute ) type pendingWalletAddressState struct { RequestedAt time.Time + Network string } var pendingWalletAddressUsers sync.Map @@ -36,7 +39,7 @@ func OnTextMessageHandle(c tb.Context) error { senderID = sender.ID } - isReplyFlow := msg.ReplyTo != nil && msg.ReplyTo.Text == ReplayAddWallet + isReplyFlow := msg.ReplyTo != nil && strings.HasPrefix(msg.ReplyTo.Text, "请发送 ") isPendingFlow := isWalletAddressPending(senderID) if !isReplyFlow && !isPendingFlow { return nil @@ -47,24 +50,25 @@ func OnTextMessageHandle(c tb.Context) error { } msgText := strings.TrimSpace(msg.Text) - var err error - switch { - case isValidTronAddress(msgText): - _, err = data.AddWalletAddress(msgText) - case isValidEthereumAddress(msgText): - _, err = data.AddWalletAddressWithNetwork(mdb.NetworkEthereum, strings.ToLower(msgText)) - case isValidSolanaAddress(msgText): - _, err = data.AddWalletAddressWithNetwork(mdb.NetworkSolana, msgText) - default: - _ = c.Send(fmt.Sprintf("钱包 [%s] 添加失败:不是合法的 TRON、以太坊或 Solana 地址", msgText)) + state, _ := getPendingWalletAddressState(senderID) + if state.Network == "" { + _ = c.Send("请先选择网络,再发送地址。") return nil } + + var err error + if !isValidAddressByNetwork(state.Network, msgText) { + _ = c.Send(fmt.Sprintf("钱包 [%s] 添加失败:不是合法的 %s 地址", msgText, strings.ToUpper(state.Network))) + return nil + } + storeAddress := normalizeWalletAddressByNetwork(state.Network, msgText) + _, err = data.AddWalletAddressWithNetwork(state.Network, storeAddress) if err != nil { return c.Send(err.Error()) } pendingWalletAddressUsers.Delete(senderID) - _ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功", msgText)) + _ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功(%s)", storeAddress, strings.ToUpper(state.Network))) return WalletList(c) } @@ -96,11 +100,26 @@ func WalletList(c tb.Context) error { addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"} bots.Handle(&addBtn, func(c tb.Context) error { - if sender := c.Sender(); sender != nil { - pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{RequestedAt: time.Now()}) + chains, err := getEnabledSupportedNetworks() + if err != nil { + return c.Send("读取支持链失败:" + err.Error()) } - return c.Send(ReplayAddWallet, &tb.ReplyMarkup{ - ForceReply: true, + if len(chains) == 0 { + return c.Send("当前没有可用链,请先在后台配置 supported-assets。") + } + + rows := make([][]tb.InlineButton, 0, len(chains)) + for _, network := range chains { + btn := tb.InlineButton{ + Text: strings.ToUpper(network), + Unique: "SelectWalletNetwork", + Data: network, + } + bots.Handle(&btn, SelectWalletNetwork) + rows = append(rows, []tb.InlineButton{btn}) + } + return c.EditOrSend(ReplaySelectNetwork, &tb.ReplyMarkup{ + InlineKeyboard: rows, }) }) btnList = append(btnList, []tb.InlineButton{addBtn}) @@ -110,6 +129,22 @@ func WalletList(c tb.Context) error { }) } +func SelectWalletNetwork(c tb.Context) error { + network := strings.ToLower(strings.TrimSpace(c.Data())) + if network == "" { + return c.Send("请选择有效网络") + } + if sender := c.Sender(); sender != nil { + pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{ + RequestedAt: time.Now(), + Network: network, + }) + } + return c.Send(fmt.Sprintf(ReplayAddWallet, strings.ToUpper(network)), &tb.ReplyMarkup{ + ForceReply: true, + }) +} + func WalletInfo(c tb.Context) error { id := mathutil.MustUint(c.Data()) tokenInfo, err := data.GetWalletAddressById(id) @@ -211,3 +246,33 @@ func isWalletAddressPending(userID int64) bool { } return true } + +func getPendingWalletAddressState(userID int64) (pendingWalletAddressState, bool) { + v, ok := pendingWalletAddressUsers.Load(userID) + if !ok { + return pendingWalletAddressState{}, false + } + state, ok := v.(pendingWalletAddressState) + return state, ok +} + +func getEnabledSupportedNetworks() ([]string, error) { + assets, err := data.ListEnabledSupportedAssets() + if err != nil { + return nil, err + } + set := make(map[string]struct{}) + for _, a := range assets { + n := strings.ToLower(strings.TrimSpace(a.Network)) + if n == "" { + continue + } + set[n] = struct{}{} + } + networks := make([]string, 0, len(set)) + for n := range set { + networks = append(networks, n) + } + sort.Strings(networks) + return networks, nil +} diff --git a/src/telegram/utils.go b/src/telegram/utils.go index f525439..fde9c66 100644 --- a/src/telegram/utils.go +++ b/src/telegram/utils.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "strings" + "github.com/assimon/luuu/model/mdb" "github.com/btcsuite/btcutil/base58" "github.com/gagliardetto/solana-go" ) @@ -46,6 +47,28 @@ func isValidTronAddress(addr string) bool { return string(checksum) == string(hash2[:4]) } +func isValidAddressByNetwork(network, addr string) bool { + switch strings.ToLower(strings.TrimSpace(network)) { + case mdb.NetworkTron: + return isValidTronAddress(addr) + case mdb.NetworkSolana: + return isValidSolanaAddress(addr) + default: + // 其余 EVM 链统一使用 0x 地址校验 + return isValidEthereumAddress(addr) + } +} + +func normalizeWalletAddressByNetwork(network, addr string) string { + addr = strings.TrimSpace(addr) + switch strings.ToLower(strings.TrimSpace(network)) { + case mdb.NetworkTron, mdb.NetworkSolana: + return addr + default: + return strings.ToLower(addr) + } +} + // isValidSolanaAddress 校验 Solana Base58 地址是否合法(32 字节公钥)。 func isValidSolanaAddress(addr string) bool { addr = strings.TrimSpace(addr) diff --git a/src/util/constant/errno.go b/src/util/constant/errno.go index dfba27d..d38ca17 100644 --- a/src/util/constant/errno.go +++ b/src/util/constant/errno.go @@ -16,6 +16,8 @@ var Errno = map[int]string{ 10011: "exceeded maximum sub-order limit", 10012: "cannot switch network on a sub-order", 10013: "order is not awaiting payment", + 10014: "supported asset already exists", + 10015: "supported asset not found", } var ( @@ -34,6 +36,8 @@ var ( SubOrderLimitExceeded = Err(10011) CannotSwitchSubOrder = Err(10012) OrderNotWaitPay = Err(10013) + SupportedAssetAlreadyExists = Err(10014) + SupportedAssetNotFound = Err(10015) ) type RspError struct {