diff --git a/app/conf/const.go b/app/conf/const.go index bdcaba64..45b6810e 100644 --- a/app/conf/const.go +++ b/app/conf/const.go @@ -10,6 +10,7 @@ const Debug = false const ( Bsc = "bsc" // Binance Smart Chain Tron = "tron" + Ton = "ton" // The Open Network Aptos = "aptos" Solana = "solana" Xlayer = "xlayer" @@ -30,6 +31,7 @@ const ( UsdtSolana = "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB" SolSplToken = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" UsdtAptos = "0x357b0b74bc833e95a115ad22604854d6b0fca151cecd94111770e5d6ffc9dc2b" + UsdtTon = "0:b113a994b5024a16719f69139328eb759596c38a25f59028b146fecdc3621dfe" // EQCxE6mUtQJKFnGfaROTKOt1lZbDiiX1kCixRv7Nw2Id_sDs UsdcErc20 = "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" UsdcPolygon = "0x3c499c542cef5e3811e1192ce70d8cc03d5c3359" UsdcXlayer = "0x74b7f16337b8972027f6196a17a631ac6de26d22" @@ -50,6 +52,7 @@ const ( UsdtArbitrumDecimals = -6 // USDT Arbitrum小数位数 UsdtAptosDecimals = -6 // USDT Aptos小数位数 UsdtSolanaDecimals = -6 // USDT Solana小数位数 + UsdtTonDecimals = -6 // USDT Ton 小数位数 UsdcEthDecimals = -6 // USDC ERC20小数位数 UsdcPolygonDecimals = -6 // USDC Polygon小数位数 @@ -61,6 +64,8 @@ const ( UsdcAptosDecimals = -6 // USDC Aptos小数位数 UsdcSolanaDecimals = -6 // USDC Solana小数位数 - BscBnbDecimals = -18 // BSC BNB 小数位数 + TronTrxDecimals = -6 // Tron TRX 小数位数 + TonTonDecimals = -9 // Ton TON 小数位数 EthereumEthDecimals = -18 // Ethereum ETH 小数位数 + BscBnbDecimals = -18 // BSC BNB 小数位数 ) diff --git a/app/handler/admin/order.go b/app/handler/admin/order.go index 60f5a875..a0e30014 100644 --- a/app/handler/admin/order.go +++ b/app/handler/admin/order.go @@ -132,7 +132,8 @@ func (Order) List(ctx *gin.Context) { db = db.Where("status = ?", *req.Status) } if req.Address != "" { - db = db.Where("address like ?", "%"+req.Address+"%") + address := model.NormalizeKnownAddress(req.Address) + db = db.Where("address like ?", "%"+address+"%") } if req.TradeType != "" { db = db.Where("trade_type = ?", req.TradeType) @@ -154,6 +155,9 @@ func (Order) List(ctx *gin.Context) { return } + for i := range data { + formatAdminOrderAddress(&data[i].Order) + } base.Response(ctx, 200, data, total) } @@ -179,12 +183,19 @@ func (Order) Detail(ctx *gin.Context) { return } + formatAdminOrderAddress(&o) + base.Ok(ctx, detail{ Order: o, TxUrl: o.GetTxUrl(), }) } +func formatAdminOrderAddress(order *model.Order) { + order.Address = model.FormatTradeAddress(order.TradeType, order.Address) + order.FromAddress = model.FormatTradeAddress(order.TradeType, order.FromAddress) +} + func (Order) Paid(ctx *gin.Context) { var req paidReq if err := ctx.ShouldBindJSON(&req); err != nil { diff --git a/app/handler/admin/wallet.go b/app/handler/admin/wallet.go index ce85bc5e..add6dfe5 100644 --- a/app/handler/admin/wallet.go +++ b/app/handler/admin/wallet.go @@ -67,10 +67,7 @@ func (Wallet) Add(ctx *gin.Context) { return } - // 非大小写敏感的地址,统一转为小写存储 - if !model.AddrCaseSens(model.TradeType(wallet.TradeType)) { - wallet.MatchAddr = strings.ToLower(wallet.MatchAddr) - } + wallet.MatchAddr = model.NormalizeTradeAddress(model.TradeType(wallet.TradeType), wallet.Address) if err := model.Db.Create(&wallet).Error; err != nil { base.Error(ctx, err) @@ -164,10 +161,7 @@ func (Wallet) Mod(ctx *gin.Context) { return } - // 非大小写敏感的地址,统一转为小写存储 - if !model.AddrCaseSens(model.TradeType(w.TradeType)) { - w.MatchAddr = strings.ToLower(w.Address) - } + w.MatchAddr = model.NormalizeTradeAddress(model.TradeType(w.TradeType), w.Address) if err := model.Db.Save(&w).Error; err != nil { base.Error(ctx, err) diff --git a/app/handler/epusdt/epusdt.go b/app/handler/epusdt/epusdt.go index 13859945..ef902cb1 100644 --- a/app/handler/epusdt/epusdt.go +++ b/app/handler/epusdt/epusdt.go @@ -258,7 +258,7 @@ func (Epusdt) UpdateOrder(ctx *gin.Context) { "status": newOrder.Status, "amount": newOrder.Money, "actual_amount": newOrder.Amount, - "token": newOrder.Address, + "token": model.FormatOrderPaymentAddress(newOrder), "expiration_time": uint64(newOrder.ExpiredAt.Sub(time.Now()).Seconds()), "payment_url": model.CheckoutCounter(host, newOrder.TradeId), })) @@ -321,7 +321,7 @@ func (Epusdt) CreateTransaction(ctx *gin.Context) { "status": order.Status, "amount": order.Money, "actual_amount": order.Amount, - "token": order.Address, + "token": model.FormatOrderPaymentAddress(order), "expiration_time": uint64(order.ExpiredAt.Sub(time.Now()).Seconds()), "payment_url": model.CheckoutCounter(utils.GetRequestHost(ctx.Request), order.TradeId), })) @@ -377,7 +377,7 @@ func (Epusdt) CheckoutCounter(ctx *gin.Context) { ctx.HTML(200, string(order.TradeType+".html"), gin.H{ "http_host": uri.Host, "amount": order.Amount, - "address": order.Address, + "address": model.FormatOrderPaymentAddress(order), "expire": int64(order.ExpiredAt.Sub(time.Now()).Seconds()), "return_url": order.ReturnUrl, "usdt_rate": order.Rate, diff --git a/app/model/address.go b/app/model/address.go new file mode 100644 index 00000000..6c7a83d4 --- /dev/null +++ b/app/model/address.go @@ -0,0 +1,96 @@ +package model + +import ( + "strings" + + "github.com/v03413/bepusdt/app/conf" + "github.com/v03413/bepusdt/app/utils" +) + +type networkAddressCodec func(string) (string, bool) + +var networkAddressNormalizers = map[Network]networkAddressCodec{ + conf.Ton: utils.NormalizeTonAddress, +} + +var networkAddressFormatters = map[Network]networkAddressCodec{ + conf.Ton: utils.FormatTonAddress, +} + +// normalizeNetworkAddress 按网络将地址转换为内部匹配和索引使用的标准形式。 +func normalizeNetworkAddress(network Network, address string) (string, bool) { + address = strings.TrimSpace(address) + if address == "" { + return "", false + } + + if normalize, ok := networkAddressNormalizers[network]; ok { + return normalize(address) + } + + return address, true +} + +// NormalizeKnownAddress 使用已注册的网络规范化器尝试转换地址。 +func NormalizeKnownAddress(address string) string { + address = strings.TrimSpace(address) + if address == "" { + return "" + } + + for _, normalize := range networkAddressNormalizers { + if normalized, ok := normalize(address); ok { + return normalized + } + } + + return address +} + +// FormatNetworkAddress 按网络将内部地址转换为适合用户查看的展示格式。 +func FormatNetworkAddress(network Network, address string) string { + address = strings.TrimSpace(address) + if address == "" { + return "" + } + + if format, ok := networkAddressFormatters[network]; ok { + if formatted, ok := format(address); ok { + return formatted + } + } + + return address +} + +// NormalizeTradeAddress 按交易类型将地址转换为订单匹配使用的标准形式。 +func NormalizeTradeAddress(t TradeType, address string) string { + address = strings.TrimSpace(address) + if c, ok := registry[t]; ok { + if normalized, ok := normalizeNetworkAddress(c.Network, address); ok { + address = normalized + } + } + // 非大小写敏感的地址,统一转为小写存储 + if !AddrCaseSens(t) { + return strings.ToLower(address) + } + + return address +} + +// FormatTradeAddress 按交易类型将内部地址转换为适合用户查看和付款的展示格式。 +func FormatTradeAddress(t TradeType, address string) string { + if c, ok := registry[t]; ok { + return FormatNetworkAddress(c.Network, address) + } + + return strings.TrimSpace(address) +} + +// FormatOrderPaymentAddress 返回订单对外展示的付款地址。 +func FormatOrderPaymentAddress(order Order) string { + address := order.Address + + return FormatTradeAddress(order.TradeType, address) +} diff --git a/app/model/conf.go b/app/model/conf.go index 0739e3db..963eeac5 100644 --- a/app/model/conf.go +++ b/app/model/conf.go @@ -21,8 +21,9 @@ var defaultConf = map[ConfKey]string{ AtomUSDT: "0.01", AtomUSDC: "0.01", AtomTRX: "0.01", - AtomBNB: "0.00001", + AtomTON: "0.0001", AtomETH: "0.000001", + AtomBNB: "0.00001", MonitorMinAmount: "0.01", PaymentMinAmount: "0.01", PaymentMaxAmount: "99999", @@ -36,6 +37,8 @@ var defaultConf = map[ConfKey]string{ RpcEndpointBase: "https://base-public.nodies.app/", RpcEndpointAptos: "https://aptos-rest.publicnode.com/", RpcEndpointPlasma: "https://rpc.plasma.to/", + TonCenterV3Endpoint: "https://toncenter.com/api/v3", + TonCenterV3ApiKey: "", NotifyMaxRetry: "10", BlockHeightMaxDiff: "1000", BlockOffsetConfirm: "0", @@ -230,6 +233,19 @@ func GetTronGridApiKeys() []string { return strings.Split(GetK(RpcEndpointTronGridApiKey), ",") } +func GetTonCenterV3ApiKeys() []string { + items := strings.Split(GetC(TonCenterV3ApiKey), ",") + keys := make([]string, 0, len(items)) + for _, item := range items { + key := strings.TrimSpace(item) + if key != "" { + keys = append(keys, key) + } + } + + return keys +} + func FillDefaultConf() { var existKeys []string Db.Model(&Conf{}).Pluck("k", &existKeys) diff --git a/app/model/const.go b/app/model/const.go index 79e333c1..05b96816 100644 --- a/app/model/const.go +++ b/app/model/const.go @@ -41,8 +41,9 @@ const ( AtomUSDT ConfKey = "atom_usdt" AtomUSDC ConfKey = "atom_usdc" AtomTRX ConfKey = "atom_trx" - AtomBNB ConfKey = "atom_bnb" + AtomTON ConfKey = "atom_ton" AtomETH ConfKey = "atom_eth" + AtomBNB ConfKey = "atom_bnb" MonitorMinAmount ConfKey = "monitor_min_amount" // 监控最小金额,低于此金额的入账不进行通知 PaymentMinAmount ConfKey = "payment_min_amount" @@ -62,6 +63,8 @@ const ( RpcEndpointAptos ConfKey = "rpc_endpoint_aptos" // APTOS RPC节点 RpcEndpointTron ConfKey = "rpc_endpoint_tron" // TRON RPC节点 RpcEndpointTronGridApiKey ConfKey = "rpc_endpoint_tron_grid_api_key" // TRON RPC节点 TronGrid Api Key + TonCenterV3Endpoint ConfKey = "ton_center_v3_endpoint" // TON Center V3 节点 + TonCenterV3ApiKey ConfKey = "ton_center_v3_api_key" // TON Center V3 Api Key RateSyncCoingeckoApiUrl ConfKey = "rate_sync_coingecko_api_url" // 汇率同步 Coingecko Api URL RateSyncCoingeckoApiKey ConfKey = "rate_sync_coingecko_api_key" // 汇率同步 Coingecko Api Key @@ -96,8 +99,9 @@ const ( USDT Crypto = "USDT" USDC Crypto = "USDC" TRX Crypto = "TRX" - BNB Crypto = "BNB" + TON Crypto = "TON" ETH Crypto = "ETH" + BNB Crypto = "BNB" ) const ( Classic MatchMode = "classic" // 经典模式,精确匹配 diff --git a/app/model/order.go b/app/model/order.go index eeeace5d..cbf79d3e 100644 --- a/app/model/order.go +++ b/app/model/order.go @@ -23,6 +23,7 @@ const ( BscBnb TradeType = "bsc.bnb" EthereumEth TradeType = "ethereum.eth" + TonTon TradeType = "ton.ton" TronTrx TradeType = "tron.trx" UsdtTrc20 TradeType = "usdt.trc20" @@ -43,6 +44,7 @@ const ( UsdtAptos TradeType = "usdt.aptos" UsdcAptos TradeType = "usdc.aptos" UsdtPlasma TradeType = "usdt.plasma" + UsdtTon TradeType = "usdt.ton" ) const ( diff --git a/app/model/registry.go b/app/model/registry.go index 12e87faf..cfc61022 100644 --- a/app/model/registry.go +++ b/app/model/registry.go @@ -22,8 +22,9 @@ var supportCrypto = map[Crypto]CoinId{ USDT: "tether", USDC: "usd-coin", TRX: "tron", - BNB: "binancecoin", + TON: "the-open-network", ETH: "ethereum", + BNB: "binancecoin", } // TradeType 交易类型,当下类型开始增多,现在这里统一管理、尽量收缩配置项 @@ -129,6 +130,17 @@ var registry = map[TradeType]TradeTypeConf{ ExplorerFmt: "https://arbiscan.io/tx/%s", EndpointKey: RpcEndpointArbitrum, }, + UsdtTon: { + Alias: "USDT・Ton", + NetworkName: "Ton", + Network: conf.Ton, + Crypto: USDT, + Contract: conf.UsdtTon, + Decimal: conf.UsdtTonDecimals, + AmountRange: usdGeneralRange, + ExplorerFmt: "https://tonviewer.com/transaction/%s", + EndpointKey: TonCenterV3Endpoint, + }, UsdcErc20: { Alias: "USDC・ERC20", NetworkName: "ERC20", @@ -236,7 +248,7 @@ var registry = map[TradeType]TradeTypeConf{ Network: conf.Tron, Crypto: TRX, Native: true, - Decimal: -6, + Decimal: conf.TronTrxDecimals, AmountRange: Range{ MinAmount: decimal.NewFromFloat(0.1), MaxAmount: decimal.NewFromFloat(1000000), @@ -245,6 +257,21 @@ var registry = map[TradeType]TradeTypeConf{ EndpointKey: RpcEndpointTron, AddrCaseSens: true, }, + TonTon: { + Alias: "TON・Ton", + NetworkName: "Ton", + Network: conf.Ton, + Crypto: TON, + Native: true, + Decimal: conf.TonTonDecimals, + AmountRange: Range{ + MinAmount: decimal.NewFromFloat(0.0001), + MaxAmount: decimal.NewFromFloat(1000000), + }, + ExplorerFmt: "https://tonviewer.com/transaction/%s", + EndpointKey: TonCenterV3Endpoint, + AddrCaseSens: true, + }, EthereumEth: { Alias: "Ethereum・Eth", NetworkName: "Ethereum", diff --git a/app/model/trade.go b/app/model/trade.go index 853ede00..963516ba 100644 --- a/app/model/trade.go +++ b/app/model/trade.go @@ -44,7 +44,8 @@ func StartBuildOrder(p OrderParams) (Order, error) { if !utils.IsValidTronAddress(p.Address) && !utils.IsValidEvmAddress(p.Address) && !utils.IsValidSolanaAddress(p.Address) && - !utils.IsValidAptosAddress(p.Address) { + !utils.IsValidAptosAddress(p.Address) && + !utils.IsValidTonAddress(p.Address) { return order, fmt.Errorf("钱包地址格式错误:%s", p.Address) } @@ -52,6 +53,9 @@ func StartBuildOrder(p OrderParams) (Order, error) { if _, ok := registry[p.TradeType]; !ok { return order, fmt.Errorf("不支持的交易类型:%s", p.TradeType) } + if p.Address != "" { + p.Address = NormalizeTradeAddress(p.TradeType, p.Address) + } if _, ok := supportFiat[p.Fiat]; !ok { return order, fmt.Errorf("不支持的法币类型:%s", p.Fiat) } diff --git a/app/model/wallet.go b/app/model/wallet.go index c2bac4e0..0609b4a5 100644 --- a/app/model/wallet.go +++ b/app/model/wallet.go @@ -41,6 +41,11 @@ func (wa *Wallet) IsValid() bool { return utils.IsValidTronAddress(wa.Address) } + // Ton 地址验证 + if tradeType == TonTon || tradeType == UsdtTon { + return utils.IsValidTonAddress(wa.Address) + } + // Solana 地址验证 if tradeType == UsdtSolana || tradeType == UsdcSolana { return utils.IsValidSolanaAddress(wa.Address) diff --git a/app/task/task.go b/app/task/task.go index d7ff6358..5e2365ed 100644 --- a/app/task/task.go +++ b/app/task/task.go @@ -33,6 +33,7 @@ func Init() error { arbitrumInit() xlayerInit() baseInit() + tonInit() return nil } diff --git a/app/task/ton.go b/app/task/ton.go new file mode 100644 index 00000000..70921132 --- /dev/null +++ b/app/task/ton.go @@ -0,0 +1,565 @@ +package task + +import ( + "context" + "fmt" + "io" + "math/big" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/shopspring/decimal" + "github.com/tidwall/gjson" + "github.com/v03413/bepusdt/app/conf" + "github.com/v03413/bepusdt/app/log" + "github.com/v03413/bepusdt/app/model" + "github.com/v03413/bepusdt/app/utils" +) + +type ton struct { + client *http.Client + ltOffset int + lastLt atomic.Int64 + apiKeyCursor atomic.Uint64 + apiKeyCooldown sync.Map + seen sync.Map +} + +var tn ton + +func tonInit() { + tn = newTon() + Register(Task{Callback: tn.syncEvents, Duration: time.Second * 5}) + Register(Task{Callback: tn.tradeConfirmHandle, Duration: time.Second * 5}) +} + +func newTon() ton { + return ton{ + client: utils.NewHttpClient(), + ltOffset: 20, + } +} + +// syncEvents 同步 TON 活跃收款地址的原生币交易和 Jetton 转账。 +func (t *ton) syncEvents(ctx context.Context) { + if syncBreak(conf.Ton, 0) { + return + } + + addresses, startDate := t.getActiveAddresses() + if len(addresses) == 0 { + return + } + + var wg sync.WaitGroup + sem := make(chan struct{}, 3) + for _, address := range addresses { + wg.Add(1) + sem <- struct{}{} + go func(address string) { + defer wg.Done() + defer func() { <-sem }() + + t.fetchAccountTransfers(ctx, address, startDate) + }(address) + } + + wg.Wait() +} + +// getActiveAddresses 获取当前需要扫描的 TON 地址和最早订单时间。 +func (t *ton) getActiveAddresses() ([]string, int64) { + trades := model.GetNetworkTrades(conf.Ton) + data := make(map[string]struct{}) + var startDate int64 + + var orders []model.Order + model.Db.Model(&model.Order{}). + Where("status = ? and trade_type in (?)", model.OrderStatusWaiting, trades). + Find(&orders) + for _, order := range orders { + address := model.NormalizeTradeAddress(order.TradeType, order.Address) + if address != "" { + data[address] = struct{}{} + } + created := order.CreatedAt.Time().Unix() + if startDate == 0 || created < startDate { + startDate = created + } + } + + var wallets []model.Wallet + model.Db.Model(&model.Wallet{}). + Where("status = ? and trade_type in (?)", model.WaStatusEnable, trades). + Find(&wallets) + for _, wallet := range wallets { + address := model.NormalizeTradeAddress(model.TradeType(wallet.TradeType), wallet.MatchAddr) + if address == "" { + address = model.NormalizeTradeAddress(model.TradeType(wallet.TradeType), wallet.Address) + } + if address != "" { + data[address] = struct{}{} + } + } + + addresses := make([]string, 0, len(data)) + for address := range data { + addresses = append(addresses, address) + } + + return addresses, startDate +} + +// fetchAccountTransfers 从 TON Center V3 拉取单个账户的原生 TON 和 Jetton 入账。 +func (t *ton) fetchAccountTransfers(ctx context.Context, address string, startDate int64) { + transfers := make([]transfer, 0) + if nativeTransfers, ok := t.fetchNativeTransfers(ctx, address, startDate); ok { + transfers = append(transfers, nativeTransfers...) + } + for _, contract := range t.tonJettonContracts() { + jettonTransfers, ok := t.fetchJettonTransfers(ctx, address, contract, startDate) + if ok { + transfers = append(transfers, jettonTransfers...) + } + } + + if len(transfers) > 0 { + transferQueue.In <- transfers + } + + log.Task.Info(fmt.Sprintf("区块扫描完成(Ton) %s 成功率:%s", model.FormatNetworkAddress(conf.Ton, address), conf.GetSuccessRate(conf.Ton))) +} + +// fetchNativeTransfers 从 TON Center V3 查询账户原生 TON 入账交易。 +func (t *ton) fetchNativeTransfers(ctx context.Context, address string, startDate int64) ([]transfer, bool) { + query := url.Values{} + query.Set("account", address) + query.Set("limit", "100") + query.Set("sort", "desc") + if startDate > 0 { + query.Set("start_utime", fmt.Sprintf("%d", startDate-60)) + } + + body, ok := t.get(ctx, "/transactions", query) + if !ok { + return nil, false + } + + return t.parseNativeTransfers(body, address), true +} + +// fetchJettonTransfers 从 TON Center V3 查询账户指定 Jetton 入账交易。 +func (t *ton) fetchJettonTransfers(ctx context.Context, address string, contract string, startDate int64) ([]transfer, bool) { + query := url.Values{} + query.Set("owner_address", address) + query.Set("jetton_master", contract) + query.Set("direction", "in") + query.Set("limit", "100") + query.Set("sort", "desc") + if startDate > 0 { + query.Set("start_utime", fmt.Sprintf("%d", startDate-60)) + } + + body, ok := t.get(ctx, "/jetton/transfers", query) + if !ok { + return nil, false + } + + return t.parseJettonTransfers(body), true +} + +// parseNativeTransfers 解析 TON Center V3 账户交易为原生 TON 转账。 +func (t *ton) parseNativeTransfers(body []byte, account string) []transfer { + transfers := make([]transfer, 0) + account = t.normalizeAddress(account) + + for _, tx := range gjson.GetBytes(body, "transactions").Array() { + if tx.Get("description.aborted").Bool() { + continue + } + + msg := tx.Get("in_msg") + recv := t.normalizeAddress(msg.Get("destination").String()) + if recv == "" || recv != account { + continue + } + + amount, ok := new(big.Int).SetString(msg.Get("value").String(), 10) + if !ok || amount.Sign() <= 0 { + continue + } + + lt := t.parseLt(tx.Get("lt").String()) + t.recordLastLt(lt) + + tr := transfer{ + Network: conf.Ton, + TxHash: tx.Get("hash").String(), + Amount: decimal.NewFromBigInt(amount, model.GetTradeDecimal(model.TonTon)), + FromAddress: t.normalizeAddress(msg.Get("source").String()), + RecvAddress: recv, + Timestamp: time.Unix(tx.Get("now").Int(), 0), + TradeType: model.TonTon, + BlockNum: int(lt), + } + if t.validTransfer(tr) { + transfers = append(transfers, tr) + } + } + + return transfers +} + +// parseJettonTransfers 解析 TON Center V3 Jetton 转账为统一转账结构。 +func (t *ton) parseJettonTransfers(body []byte) []transfer { + transfers := make([]transfer, 0) + + for _, item := range gjson.GetBytes(body, "jetton_transfers").Array() { + if item.Get("transaction_aborted").Bool() { + continue + } + + contract := t.normalizeAddress(item.Get("jetton_master").String()) + tradeType, ok := model.GetContractTrade(contract) + if !ok { + continue + } + + amount, ok := new(big.Int).SetString(item.Get("amount").String(), 10) + if !ok || amount.Sign() <= 0 { + continue + } + + lt := t.parseLt(item.Get("transaction_lt").String()) + t.recordLastLt(lt) + + tr := transfer{ + Network: conf.Ton, + TxHash: item.Get("transaction_hash").String(), + Amount: decimal.NewFromBigInt(amount, model.GetTradeDecimal(tradeType)), + FromAddress: t.normalizeAddress(item.Get("source").String()), + RecvAddress: t.normalizeAddress(item.Get("destination").String()), + Timestamp: time.Unix(item.Get("transaction_now").Int(), 0), + TradeType: tradeType, + BlockNum: int(lt), + } + if t.validTransfer(tr) { + transfers = append(transfers, tr) + } + } + + return transfers +} + +// validTransfer 过滤字段不完整或本轮已经处理过的 TON 转账。 +func (t *ton) validTransfer(tr transfer) bool { + if tr.TxHash == "" || tr.FromAddress == "" || tr.RecvAddress == "" || tr.Amount.IsZero() { + return false + } + + key := strings.Join([]string{tr.TxHash, string(tr.TradeType), tr.FromAddress, tr.RecvAddress, tr.Amount.String()}, ":") + if _, loaded := t.seen.LoadOrStore(key, struct{}{}); loaded { + return false + } + + return true +} + +// tradeConfirmHandle 确认 TON 网络中处于确认中的订单。 +func (t *ton) tradeConfirmHandle(ctx context.Context) { + var orders = getConfirmingOrders(model.GetNetworkTrades(conf.Ton)) + var wg sync.WaitGroup + + for _, order := range orders { + wg.Add(1) + go func(o model.Order) { + defer wg.Done() + + if model.GetC(model.BlockOffsetConfirm) == "1" { + last := t.lastLt.Load() + if last == 0 || int(last)-o.RefBlockNum < t.ltOffset { + return + } + } + + t.confirmOrder(ctx, o) + }(order) + } + + wg.Wait() +} + +// confirmOrder 从 TON Center V3 重新读取交易并确认订单最终状态。 +func (t *ton) confirmOrder(ctx context.Context, order model.Order) { + if order.TradeType == model.TonTon { + t.confirmNativeOrder(ctx, order) + return + } + + t.confirmJettonOrder(ctx, order) +} + +// confirmNativeOrder 通过交易哈希确认原生 TON 订单。 +func (t *ton) confirmNativeOrder(ctx context.Context, order model.Order) { + query := url.Values{} + query.Set("account", model.NormalizeTradeAddress(order.TradeType, order.Address)) + query.Set("hash", order.RefHash) + query.Set("limit", "1") + + body, ok := t.get(ctx, "/transactions", query) + if !ok { + return + } + + for _, tx := range gjson.GetBytes(body, "transactions").Array() { + if tx.Get("hash").String() != order.RefHash || tx.Get("description.aborted").Bool() { + continue + } + + msg := tx.Get("in_msg") + if t.normalizeAddress(msg.Get("destination").String()) == model.NormalizeTradeAddress(order.TradeType, order.Address) { + markFinalConfirmed(order) + + return + } + } +} + +// confirmJettonOrder 通过交易 LT 和哈希确认 TON Jetton 订单。 +func (t *ton) confirmJettonOrder(ctx context.Context, order model.Order) { + confMap := model.GetAllTradeConfig() + tradeConf, ok := confMap[string(order.TradeType)] + if !ok || tradeConf.Contract == "" { + return + } + + contract := t.normalizeAddress(tradeConf.Contract) + query := url.Values{} + query.Set("owner_address", model.NormalizeTradeAddress(order.TradeType, order.Address)) + query.Set("jetton_master", contract) + query.Set("direction", "in") + query.Set("start_lt", fmt.Sprintf("%d", order.RefBlockNum)) + query.Set("end_lt", fmt.Sprintf("%d", order.RefBlockNum)) + query.Set("limit", "10") + + body, ok := t.get(ctx, "/jetton/transfers", query) + if !ok { + return + } + + for _, item := range gjson.GetBytes(body, "jetton_transfers").Array() { + if item.Get("transaction_hash").String() != order.RefHash || item.Get("transaction_aborted").Bool() { + continue + } + + if t.normalizeAddress(item.Get("destination").String()) == model.NormalizeTradeAddress(order.TradeType, order.Address) { + markFinalConfirmed(order) + + return + } + } +} + +// get 调用 TON Center V3 GET 接口并处理通用状态记录。 +func (t *ton) get(ctx context.Context, path string, query url.Values) ([]byte, bool) { + reqURL := t.endpoint(path) + if len(query) > 0 { + reqURL += "?" + query.Encode() + } + + allKeys := model.GetTonCenterV3ApiKeys() + if len(allKeys) == 0 { + body, statusCode, err := t.getOnce(ctx, reqURL, "") + if err != nil { + conf.RecordFailure(conf.Ton) + log.Task.Warn("ton center Error sending request:", err) + + return nil, false + } + if statusCode != http.StatusOK { + conf.RecordFailure(conf.Ton) + log.Task.Warn("ton center Error response status code:", statusCode) + + return nil, false + } + + conf.RecordSuccess(conf.Ton) + + return body, true + } + + keys := t.availableApiKeys(allKeys) + if len(keys) == 0 { + conf.RecordFailure(conf.Ton) + log.Task.Warn("ton center Error all Api Keys are cooling down") + + return nil, false + } + + var lastStatusCode int + for _, apiKey := range keys { + body, statusCode, err := t.getOnce(ctx, reqURL, apiKey) + if err != nil { + conf.RecordFailure(conf.Ton) + log.Task.Warn("ton center Error sending request:", err) + + return nil, false + } + + lastStatusCode = statusCode + if statusCode == http.StatusTooManyRequests { + t.cooldownApiKey(apiKey) + log.Task.Warn("ton center Api Key rate limited, switching key...") + + continue + } + if statusCode != http.StatusOK { + conf.RecordFailure(conf.Ton) + log.Task.Warn("ton center Error response status code:", statusCode) + + return nil, false + } + + conf.RecordSuccess(conf.Ton) + + return body, true + } + + conf.RecordFailure(conf.Ton) + if lastStatusCode == http.StatusTooManyRequests { + log.Task.Warn("ton center Error all Api Keys are rate limited") + } else { + log.Task.Warn("ton center Error no available Api Key") + } + + return nil, false +} + +// getOnce 使用指定 Api Key 对 TON Center V3 发起一次 GET 请求。 +func (t *ton) getOnce(ctx context.Context, reqURL string, apiKey string) ([]byte, int, error) { + req, err := http.NewRequestWithContext(ctx, "GET", reqURL, nil) + if err != nil { + log.Task.Warn("ton center Error creating request:", err) + + return nil, 0, err + } + if apiKey != "" { + req.Header.Set("X-API-Key", apiKey) + } + + resp, err := t.client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, resp.StatusCode, nil + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + + return body, resp.StatusCode, nil +} + +// availableApiKeys 返回当前未冷却的 TON Center V3 Api Key,并按轮换游标排序。 +func (t *ton) availableApiKeys(keys []string) []string { + now := time.Now() + available := make([]string, 0, len(keys)) + for _, key := range keys { + if until, ok := t.apiKeyCooldown.Load(key); ok { + if cooldownUntil, ok := until.(time.Time); ok && now.Before(cooldownUntil) { + continue + } + t.apiKeyCooldown.Delete(key) + } + available = append(available, key) + } + if len(available) == 0 { + return nil + } + + start := int(t.apiKeyCursor.Add(1) % uint64(len(available))) + rotated := make([]string, 0, len(available)) + rotated = append(rotated, available[start:]...) + rotated = append(rotated, available[:start]...) + + return rotated +} + +// cooldownApiKey 将触发频率限制的 TON Center V3 Api Key 暂时冷却。 +func (t *ton) cooldownApiKey(apiKey string) { + t.apiKeyCooldown.Store(apiKey, time.Now().Add(10*time.Second)) +} + +// tonJettonContracts 获取 TON 网络已注册的 Jetton 主合约地址。 +func (t *ton) tonJettonContracts() []string { + confMap := model.GetAllTradeConfig() + contracts := make([]string, 0) + seen := make(map[string]struct{}) + for _, tradeConf := range confMap { + if tradeConf.Network != conf.Ton || tradeConf.Native || tradeConf.Contract == "" { + continue + } + + contract := t.normalizeAddress(tradeConf.Contract) + if contract == "" { + continue + } + if _, ok := seen[contract]; ok { + continue + } + + seen[contract] = struct{}{} + contracts = append(contracts, contract) + } + + return contracts +} + +// recordLastLt 记录当前进程已观察到的最大 TON 逻辑时间。 +func (t *ton) recordLastLt(lt int64) { + for { + current := t.lastLt.Load() + if lt <= current { + return + } + if t.lastLt.CompareAndSwap(current, lt) { + return + } + } +} + +// parseLt 将 TON Center V3 的字符串 LT 转成整数。 +func (t *ton) parseLt(value string) int64 { + lt, err := strconv.ParseInt(value, 10, 64) + if err != nil { + return 0 + } + + return lt +} + +// normalizeAddress 将 TON 地址统一转换为 raw 地址。 +func (t *ton) normalizeAddress(address string) string { + if normalized, ok := utils.NormalizeTonAddress(address); ok { + return normalized + } + + return strings.TrimSpace(address) +} + +// endpoint 拼接当前配置的 TON Center V3 端点和请求路径。 +func (t *ton) endpoint(path string) string { + endpoint := strings.TrimRight(model.Endpoint(conf.Ton), "/") + + return endpoint + path +} diff --git a/app/utils/utils.go b/app/utils/utils.go index 91a4e24c..cefa7c13 100644 --- a/app/utils/utils.go +++ b/app/utils/utils.go @@ -3,6 +3,8 @@ package utils import ( "crypto/md5" "crypto/sha256" + "encoding/base64" + "encoding/binary" "encoding/hex" "fmt" "math" @@ -12,6 +14,7 @@ import ( "os" "regexp" "sort" + "strconv" "strings" "time" "unicode" @@ -100,6 +103,90 @@ func IsValidTronAddress(address string) bool { return match && err == nil } +// IsValidTonAddress 校验 TON 地址是否为 raw 或 user-friendly 格式。 +func IsValidTonAddress(address string) bool { + _, ok := NormalizeTonAddress(address) + + return ok +} + +// NormalizeTonAddress 将 TON raw/user-friendly 地址统一转换为 raw 地址。 +func NormalizeTonAddress(address string) (string, bool) { + address = strings.TrimSpace(address) + if address == "" { + return "", false + } + + rawMatch, _ := regexp.MatchString(`^-?\d+:[0-9a-fA-F]{64}$`, address) + if rawMatch { + parts := strings.SplitN(address, ":", 2) + + return parts[0] + ":" + strings.ToLower(parts[1]), true + } + + data, err := base64.RawURLEncoding.DecodeString(address) + if err != nil { + data, err = base64.RawStdEncoding.DecodeString(address) + } + if err != nil || len(data) != 36 { + return "", false + } + + if tonCRC16(data[:34]) != binary.BigEndian.Uint16(data[34:]) { + return "", false + } + + workchain := int(int8(data[1])) + + return fmt.Sprintf("%d:%s", workchain, hex.EncodeToString(data[2:34])), true +} + +// FormatTonAddress 将 TON 地址转换为 non-bounceable user-friendly 格式用于展示和付款。 +func FormatTonAddress(address string) (string, bool) { + raw, ok := NormalizeTonAddress(address) + if !ok { + return "", false + } + + parts := strings.SplitN(raw, ":", 2) + workchain, err := strconv.Atoi(parts[0]) + if err != nil || workchain < -128 || workchain > 127 { + return "", false + } + + hash, err := hex.DecodeString(parts[1]) + if err != nil || len(hash) != 32 { + return "", false + } + + data := make([]byte, 34) + data[0] = 0x51 + data[1] = byte(int8(workchain)) + copy(data[2:], hash) + + crc := tonCRC16(data) + full := append(data, byte(crc>>8), byte(crc)) + + return base64.RawURLEncoding.EncodeToString(full), true +} + +// tonCRC16 计算 TON user-friendly 地址使用的 CRC16-CCITT 校验值。 +func tonCRC16(data []byte) uint16 { + var crc uint16 + for _, b := range data { + crc ^= uint16(b) << 8 + for i := 0; i < 8; i++ { + if crc&0x8000 != 0 { + crc = (crc << 1) ^ 0x1021 + } else { + crc <<= 1 + } + } + } + + return crc +} + func IsValidEvmAddress(address string) bool { if len(address) != 42 || !strings.HasPrefix(address, "0x") { diff --git a/docs/api/mqtt.md b/docs/api/mqtt.md index cdad1521..76c43b2d 100644 --- a/docs/api/mqtt.md +++ b/docs/api/mqtt.md @@ -67,6 +67,7 @@ bepusdt/transfer/{network} | Base | `bepusdt/transfer/base` | USDC | | Solana | `bepusdt/transfer/solana` | USDT、USDC | | Aptos | `bepusdt/transfer/aptos` | USDT、USDC | +| TON | `bepusdt/transfer/ton` | USDT、TON | | X Layer | `bepusdt/transfer/xlayer` | USDT、USDC | | Plasma | `bepusdt/transfer/plasma` | USDT | diff --git a/docs/ton/readme.md b/docs/ton/readme.md new file mode 100644 index 00000000..5482eec3 --- /dev/null +++ b/docs/ton/readme.md @@ -0,0 +1,87 @@ +# TON Center V3 + +> BEpusdt 的 TON 网络扫描使用 TON Center V3 API。 +> 系统会通过 V3 接口查询原生 TON 转账和 Jetton 转账,并将解析后的交易继续交给统一的订单匹配、订单回调和 MQTT 发布流程。 + +## 默认端点 + +系统默认使用以下 TON Center V3 端点: + +```text +https://toncenter.com/api/v3 +``` + +一般情况下不建议修改该端点。只有在您使用自建 TON Center V3 服务,或使用兼容 TON Center V3 接口的第三方服务时,才需要修改。 + +## 为什么建议配置 Api Key + +TON Center 的公开接口存在频率限制。未配置 Api Key 时,扫描地址、确认订单或持续 MQTT 监听都更容易受到限流影响。 + +建议配置 TON Center V3 Api Key,原因如下: + +- 提高 TON 扫描稳定性。 +- 降低因接口限流导致的订单确认延迟。 +- 持续监听 TON 地址并发布 MQTT 消息时更可靠。 +- 便于后续升级付费额度,而不需要修改系统代码。 + +## 获取 Ton Center Api Key + +1. 在 Telegram 中点击访问 [@tonceter](https://t.me/toncenter) 机器人,点击 Start 开始。 +2. Toncenter 机器人会回复您一条欢迎消息,点击消息底部的“管理 API 密钥 (Manage API Keys)”按钮,将会打开 toncenter bot 小程序。 +3. 点击小程序底部的“创建 API 密钥 (Create API Key)”按钮,创建新的 Api Key。 +4. 在 API 密钥详情 (API Key Detail) 页面,名称 (Name) / 描述 (Description) 随便填,网络 (Network) 选择“主网 (Mainnet)”,然后点击“创建 (Create)”按钮完成创建。 +5. 复制生成的 Api Key,填入 BEpusdt 区块节点 TON Center V3 Api Key 输入框中。 + +> 说明:如果你有多个 TON Center Api Key,可以全部填入 BEpusdt,用半角逗号分隔。 + +## 配置 Api Key + +登录 BEpusdt 后台,进入: + +```text +系统管理 -> 区块节点 -> TON 网络 +``` + +然后确认或填写: + +```text +TON Center V3 Endpoint: https://toncenter.com/api/v3 (保持不变) +TON Center V3 Api Key: 你的 TON Center Api Key +``` + +保存后会立即生效。系统请求 TON Center V3 时会通过 HTTP Header 发送: + +```http +X-API-Key: 你的 TON Center Api Key +``` + +如果需要配置多个 Api Key,请使用半角逗号分隔,例如: + +```text +key-1,key-2,key-3 +``` + +系统会在请求 TON Center V3 时从这些 Key 中选择一个使用,以降低单个 Key 触发频率限制的概率。 + +## MQTT 持续监听 TON + +如果需要在没有待支付订单时也持续监听 TON 地址并发布 MQTT 消息,请进入: + +```text +系统管理 -> 基本设置 -> MQTT 设置 +``` + +在「区块链网络」中勾选 `Ton`,并确保 MQTT Host、Port、Topic 前缀等配置正确。 + +TON 网络的 MQTT Topic 格式为: + +```text +bepusdt/transfer/ton +``` + +## 注意事项 + +- `TON Center V3 Endpoint` 不是传统 JSON-RPC 端点,不要填写 `/api/v2/jsonRPC`。 +- 推荐使用 TonCenter 官方 `https://toncenter.com/api/v3`,除非你明确知道自定义端点兼容 TON Center V3。 +- Api Key 不是钱包私钥,不涉及链上签名,但仍建议妥善保管。 +- 如果出现接口限流或扫描延迟,优先检查 Api Key、端点可用性和 TON Center 套餐额度。 diff --git a/static/payment/views/ton.ton.html b/static/payment/views/ton.ton.html new file mode 100644 index 00000000..2e33c2e5 --- /dev/null +++ b/static/payment/views/ton.ton.html @@ -0,0 +1,106 @@ + + +
+ + ++ + 请使用 Ton 网络进行转账 + Ton +
+ ++ + 请使用 Ton 网络进行转账 + Ton +
+ +