Merge pull request #31 from GMwalletApp/dev-payment

Dev payment
This commit is contained in:
Ginta
2026-04-15 02:18:25 +08:00
committed by GitHub
18 changed files with 1076 additions and 106 deletions
@@ -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)
}
+34
View File
@@ -22,5 +22,39 @@ func MdbTableInit() {
// panic(err) // panic(err)
return 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)
}
}
+123
View File
@@ -0,0 +1,123 @@
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
}
deleted, err := getSupportedAssetByNetworkAndTokenUnscoped(network, token)
if err != nil {
return nil, err
}
if deleted.ID > 0 && deleted.DeletedAt.Valid {
deleted.Status = status
deleted.DeletedAt.Valid = false
if err := dao.Mdb.Unscoped().Save(deleted).Error; err != nil {
return nil, err
}
return deleted, nil
}
asset := &mdb.SupportedAsset{
Network: network,
Token: token,
Status: status,
}
err = dao.Mdb.Create(asset).Error
return asset, err
}
func getSupportedAssetByNetworkAndTokenUnscoped(network, token string) (*mdb.SupportedAsset, error) {
network, token = normalizeNetworkToken(network, token)
asset := new(mdb.SupportedAsset)
err := dao.Mdb.Unscoped().Model(asset).
Where("network = ?", network).
Where("token = ?", token).
Limit(1).Find(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
}
+12
View File
@@ -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"
}
+3
View File
@@ -9,6 +9,9 @@ const (
NetworkTron = "tron" NetworkTron = "tron"
NetworkSolana = "solana" NetworkSolana = "solana"
NetworkEthereum = "ethereum" NetworkEthereum = "ethereum"
NetworkBsc = "bsc"
NetworkPolygon = "polygon"
NetworkPlasma = "plasma"
) )
type WalletAddress struct { type WalletAddress struct {
+10
View File
@@ -0,0 +1,10 @@
package response
type NetworkTokenSupport struct {
Network string `json:"network"`
Tokens []string `json:"tokens"`
}
type SupportedAssetsResponse struct {
Supports []NetworkTokenSupport `json:"supports"`
}
+68 -24
View File
@@ -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() { defer func() {
if err := recover(); err != nil { 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") var usdt, usdc common.Address
usdc := common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") var polygonUsdcE common.Address
var tokenSym string switch chainNetwork {
switch contract { case mdb.NetworkEthereum:
case usdt: usdt = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
tokenSym = "USDT" usdc = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
case usdc: case mdb.NetworkBsc:
tokenSym = "USDC" 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: default:
log.Sugar.Warnf("[ETH-WS] skip unsupported contract %s", contract.Hex())
return 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()) walletAddr := strings.ToLower(toAddr.Hex())
if rawValue == nil || rawValue.Sign() <= 0 { 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 return
} }
decimalQuant := decimal.NewFromBigInt(rawValue, 0) decimalQuant := decimal.NewFromBigInt(rawValue, 0)
amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2) amount := math.MustParsePrecFloat64(decimalQuant.Div(decimal.NewFromInt(1_000_000)).InexactFloat64(), 2)
if amount <= 0 { 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 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 { 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 return
} }
if tradeID == "" { 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 return
} }
order, err := data.GetOrderInfoByTradeId(tradeID) order, err := data.GetOrderInfoByTradeId(tradeID)
if err != nil { 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 return
} }
if strings.ToLower(strings.TrimSpace(order.Network)) != mdb.NetworkEthereum { if strings.ToLower(strings.TrimSpace(order.Network)) != chainNetwork {
log.Sugar.Warnf("[ETH-%s][%s] skip trade_id=%s network=%q", tokenSym, walletAddr, tradeID, order.Network) log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s network=%q", net, tokenSym, walletAddr, tradeID, order.Network)
return return
} }
if strings.ToUpper(strings.TrimSpace(order.Token)) != tokenSym { 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 return
} }
req := &request.OrderProcessingRequest{ req := &request.OrderProcessingRequest{
ReceiveAddress: walletAddr, ReceiveAddress: walletAddr,
Token: tokenSym, Token: tokenSym,
Network: mdb.NetworkEthereum, Network: chainNetwork,
TradeId: tradeID, TradeId: tradeID,
Amount: amount, Amount: amount,
BlockTransactionId: txHash, BlockTransactionId: txHash,
@@ -325,15 +363,15 @@ func TryProcessEthereumERC20Transfer(contract common.Address, toAddr common.Addr
err = OrderProcessing(req) err = OrderProcessing(req)
if err != nil { if err != nil {
if errors.Is(err, constant.OrderBlockAlreadyProcess) || errors.Is(err, constant.OrderStatusConflict) { 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 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 return
} }
sendPaymentNotification(order) 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) { func sendPaymentNotification(order *mdb.Orders) {
@@ -372,6 +410,12 @@ func networkDisplay(n string) string {
return "Solana" return "Solana"
case mdb.NetworkEthereum: case mdb.NetworkEthereum:
return "Ethereum" return "Ethereum"
case mdb.NetworkBsc:
return "BSC"
case mdb.NetworkPolygon:
return "Polygon"
case mdb.NetworkPlasma:
return "Plasma"
default: default:
if n == "" { if n == "" {
return "Tron" return "Tron"
+6
View File
@@ -64,6 +64,12 @@ func RegisterRoute(e *echo.Echo) {
// gmpay v1 routes // gmpay v1 routes
gmpayV1 := paymentRoute.Group("/gmpay/v1") gmpayV1 := paymentRoute.Group("/gmpay/v1")
gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign()) gmpayV1.POST("/order/create-transaction", comm.Ctrl.CreateTransaction, middleware.CheckApiSign())
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 // wallet management routes
walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken()) walletV1 := gmpayV1.Group("/wallet", middleware.CheckApiToken())
+129 -1
View File
@@ -54,11 +54,30 @@ func setupTestEnv(t *testing.T) *echo.Echo {
} }
// ensure tables exist (MdbTableInit uses sync.Once, so migrate directly) // 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 // seed wallet addresses
dao.Mdb.Create(&mdb.WalletAddress{Network: mdb.NetworkTron, Address: "TTestTronAddress001", Status: mdb.TokenStatusEnable}) 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}) 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() e := echo.New()
RegisterRoute(e) RegisterRoute(e)
@@ -231,6 +250,115 @@ func parseResp(t *testing.T, rec *httptest.ResponseRecorder) map[string]interfac
return resp 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)
}
// recreate after delete should restore soft-deleted row, not unique-conflict
rec = doPostWithToken(e, "/payments/gmpay/v1/supported-assets/add", map[string]interface{}{
"network": "arbitrum",
"token": "usdc",
"status": 1,
})
resp = parseResp(t, rec)
if resp["status_code"].(float64) != 200 {
t.Fatalf("recreate after delete failed: %v", resp)
}
}
// TestWalletAddAndList tests adding wallets via API and listing them. // TestWalletAddAndList tests adding wallets via API and listing them.
func TestWalletAddAndList(t *testing.T) { func TestWalletAddAndList(t *testing.T) {
e := setupTestEnv(t) e := setupTestEnv(t)
+3
View File
@@ -8,6 +8,9 @@ import (
func Start() { func Start() {
log.Sugar.Info("[task] Starting task scheduler...") log.Sugar.Info("[task] Starting task scheduler...")
go StartEthereumWebSocketListener() go StartEthereumWebSocketListener()
go StartBscWebSocketListener()
//go StartPolygonWebSocketListener()
//go StartPlasmaWebSocketListener()
c := cron.New() c := cron.New()
// trc20钱包监听 // trc20钱包监听
+112
View File
@@ -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
}
+7 -42
View File
@@ -2,7 +2,6 @@ package task
import ( import (
"context" "context"
"fmt"
"math/big" "math/big"
"strings" "strings"
"sync/atomic" "sync/atomic"
@@ -38,7 +37,7 @@ var ethWatchedRecipients atomic.Pointer[ethRecipientSnapshot]
func StartEthereumWebSocketListener() { func StartEthereumWebSocketListener() {
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum) wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum)
if err != nil { if err != nil {
log.Sugar.Fatalf("Failed to get wallet addresses: %v", err) log.Sugar.Errorf("Failed to get wallet addresses: %v", err)
return return
} }
StoreEthRecipientsFromWallets(wallets) StoreEthRecipientsFromWallets(wallets)
@@ -55,16 +54,6 @@ func StartEthereumWebSocketListener() {
} }
}() }()
wsURL := "wss://ethereum.publicnode.com" 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{ query := ethereum.FilterQuery{
Addresses: []common.Address{ Addresses: []common.Address{
usdtContract, usdtContract,
@@ -73,45 +62,22 @@ func StartEthereumWebSocketListener() {
Topics: [][]common.Hash{}, Topics: [][]common.Hash{},
} }
// 订阅日志(核心) runEvmWsLogListener("[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
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 { if len(vLog.Topics) < 3 {
continue return
} }
event := vLog.Topics[0].String() event := vLog.Topics[0].String()
if event != transferEventHash.String() { if event != transferEventHash.String() {
continue return
} }
// value from data
amount := new(big.Int).SetBytes(vLog.Data) amount := new(big.Int).SetBytes(vLog.Data)
if event != transferEventHash.String() {
continue
}
toAddr := common.HexToAddress(vLog.Topics[2].Hex()) toAddr := common.HexToAddress(vLog.Topics[2].Hex())
if !isWatchedEthRecipient(toAddr) { if !isWatchedEthRecipient(toAddr) {
continue return
} }
var blockTsMs int64 var blockTsMs int64
@@ -123,9 +89,8 @@ func StartEthereumWebSocketListener() {
blockTsMs = int64(header.Time) * 1000 blockTsMs = int64(header.Time) * 1000
} }
service.TryProcessEthereumERC20Transfer(vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs) service.TryProcessEvmERC20Transfer(mdb.NetworkEthereum, vLog.Address, toAddr, amount, vLog.TxHash.Hex(), blockTsMs)
} })
}
} }
func StoreEthRecipientsFromWallets(wallets []mdb.WalletAddress) int { func StoreEthRecipientsFromWallets(wallets []mdb.WalletAddress) int {
+81
View File
@@ -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
}
+108
View File
@@ -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
}
+114
View File
@@ -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
}
+82 -17
View File
@@ -2,6 +2,7 @@ package telegram
import ( import (
"fmt" "fmt"
"sort"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -14,12 +15,14 @@ import (
) )
const ( const (
ReplayAddWallet = "请发给我 TRON(T 开头)、以太坊主网(0x 开头)或 Solana 收款地址" ReplaySelectNetwork = "请选择要添加的钱包网络"
ReplayAddWallet = "请发送 %s 网络收款地址"
pendingWalletAddressTTL = 5 * time.Minute pendingWalletAddressTTL = 5 * time.Minute
) )
type pendingWalletAddressState struct { type pendingWalletAddressState struct {
RequestedAt time.Time RequestedAt time.Time
Network string
} }
var pendingWalletAddressUsers sync.Map var pendingWalletAddressUsers sync.Map
@@ -36,7 +39,7 @@ func OnTextMessageHandle(c tb.Context) error {
senderID = sender.ID senderID = sender.ID
} }
isReplyFlow := msg.ReplyTo != nil && msg.ReplyTo.Text == ReplayAddWallet isReplyFlow := msg.ReplyTo != nil && strings.HasPrefix(msg.ReplyTo.Text, "请发送 ")
isPendingFlow := isWalletAddressPending(senderID) isPendingFlow := isWalletAddressPending(senderID)
if !isReplyFlow && !isPendingFlow { if !isReplyFlow && !isPendingFlow {
return nil return nil
@@ -47,24 +50,25 @@ func OnTextMessageHandle(c tb.Context) error {
} }
msgText := strings.TrimSpace(msg.Text) msgText := strings.TrimSpace(msg.Text)
var err error state, _ := getPendingWalletAddressState(senderID)
switch { if state.Network == "" {
case isValidTronAddress(msgText): _ = c.Send("请先选择网络,再发送地址。")
_, 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))
return nil 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 { if err != nil {
return c.Send(err.Error()) return c.Send(err.Error())
} }
pendingWalletAddressUsers.Delete(senderID) pendingWalletAddressUsers.Delete(senderID)
_ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功", msgText)) _ = c.Send(fmt.Sprintf("钱包 [%s] 添加成功%s", storeAddress, strings.ToUpper(state.Network)))
return WalletList(c) return WalletList(c)
} }
@@ -96,11 +100,26 @@ func WalletList(c tb.Context) error {
addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"} addBtn := tb.InlineButton{Text: "添加钱包地址", Unique: "AddWallet"}
bots.Handle(&addBtn, func(c tb.Context) error { bots.Handle(&addBtn, func(c tb.Context) error {
if sender := c.Sender(); sender != nil { chains, err := getEnabledSupportedNetworks()
pendingWalletAddressUsers.Store(sender.ID, pendingWalletAddressState{RequestedAt: time.Now()}) if err != nil {
return c.Send("读取支持链失败:" + err.Error())
} }
return c.Send(ReplayAddWallet, &tb.ReplyMarkup{ if len(chains) == 0 {
ForceReply: true, 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}) 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 { func WalletInfo(c tb.Context) error {
id := mathutil.MustUint(c.Data()) id := mathutil.MustUint(c.Data())
tokenInfo, err := data.GetWalletAddressById(id) tokenInfo, err := data.GetWalletAddressById(id)
@@ -211,3 +246,33 @@ func isWalletAddressPending(userID int64) bool {
} }
return true 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
}
+23
View File
@@ -5,6 +5,7 @@ import (
"encoding/hex" "encoding/hex"
"strings" "strings"
"github.com/assimon/luuu/model/mdb"
"github.com/btcsuite/btcutil/base58" "github.com/btcsuite/btcutil/base58"
"github.com/gagliardetto/solana-go" "github.com/gagliardetto/solana-go"
) )
@@ -46,6 +47,28 @@ func isValidTronAddress(addr string) bool {
return string(checksum) == string(hash2[:4]) 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 字节公钥)。 // isValidSolanaAddress 校验 Solana Base58 地址是否合法(32 字节公钥)。
func isValidSolanaAddress(addr string) bool { func isValidSolanaAddress(addr string) bool {
addr = strings.TrimSpace(addr) addr = strings.TrimSpace(addr)
+4
View File
@@ -16,6 +16,8 @@ var Errno = map[int]string{
10011: "exceeded maximum sub-order limit", 10011: "exceeded maximum sub-order limit",
10012: "cannot switch network on a sub-order", 10012: "cannot switch network on a sub-order",
10013: "order is not awaiting payment", 10013: "order is not awaiting payment",
10014: "supported asset already exists",
10015: "supported asset not found",
} }
var ( var (
@@ -34,6 +36,8 @@ var (
SubOrderLimitExceeded = Err(10011) SubOrderLimitExceeded = Err(10011)
CannotSwitchSubOrder = Err(10012) CannotSwitchSubOrder = Err(10012)
OrderNotWaitPay = Err(10013) OrderNotWaitPay = Err(10013)
SupportedAssetAlreadyExists = Err(10014)
SupportedAssetNotFound = Err(10015)
) )
type RspError struct { type RspError struct {