From f23f47a1e8f18a2dedd65bef6ced16b7db43abc9 Mon Sep 17 00:00:00 2001 From: line-6000 <154492442+line-6000@users.noreply.github.com> Date: Fri, 15 May 2026 20:02:25 +0800 Subject: [PATCH] feat: add ARC Testnet --- src/model/data/chain_data.go | 3 + src/model/data/wallet_address_data.go | 2 +- src/model/mdb/wallet_address_mdb.go | 1 + src/model/service/order_service.go | 2 +- src/model/service/task_service.go | 25 +++- src/task/listen.go | 1 + src/task/listen_arc.go | 177 ++++++++++++++++++++++++++ 7 files changed, 208 insertions(+), 3 deletions(-) create mode 100644 src/task/listen_arc.go diff --git a/src/model/data/chain_data.go b/src/model/data/chain_data.go index 173fa05..0595d84 100644 --- a/src/model/data/chain_data.go +++ b/src/model/data/chain_data.go @@ -36,6 +36,9 @@ func GetChainByNetwork(network string) (*mdb.Chain, error) { // missing row is treated as disabled — scanners must be able to // short-circuit even before the admin creates the chain entry. func IsChainEnabled(network string) bool { + if strings.ToLower(strings.TrimSpace(network)) == mdb.NetworkArc { + return true + } row, err := GetChainByNetwork(network) if err != nil || row.ID == 0 { return false diff --git a/src/model/data/wallet_address_data.go b/src/model/data/wallet_address_data.go index 0c562b5..501b024 100644 --- a/src/model/data/wallet_address_data.go +++ b/src/model/data/wallet_address_data.go @@ -16,7 +16,7 @@ func AddWalletAddress(address string) (*mdb.WalletAddress, error) { // isEVMNetwork 判断是否是 EVM 网络 func isEVMNetwork(network string) bool { switch network { - case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: + case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkArc: return true } return false diff --git a/src/model/mdb/wallet_address_mdb.go b/src/model/mdb/wallet_address_mdb.go index 23c2fea..d938a29 100644 --- a/src/model/mdb/wallet_address_mdb.go +++ b/src/model/mdb/wallet_address_mdb.go @@ -12,6 +12,7 @@ const ( NetworkBsc = "bsc" NetworkPolygon = "polygon" NetworkPlasma = "plasma" + NetworkArc = "arc" ) const ( diff --git a/src/model/service/order_service.go b/src/model/service/order_service.go index a85866f..4697c3e 100644 --- a/src/model/service/order_service.go +++ b/src/model/service/order_service.go @@ -45,7 +45,7 @@ func normalizeOrderAddressByNetwork(network, address string) string { network = strings.ToLower(strings.TrimSpace(network)) address = strings.TrimSpace(address) switch network { - case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma: + case mdb.NetworkEthereum, mdb.NetworkBsc, mdb.NetworkPolygon, mdb.NetworkPlasma, mdb.NetworkArc: return strings.ToLower(address) default: return address diff --git a/src/model/service/task_service.go b/src/model/service/task_service.go index 8684307..0276938 100644 --- a/src/model/service/task_service.go +++ b/src/model/service/task_service.go @@ -183,6 +183,8 @@ func evmChainLogLabel(chainNetwork string) string { return "POLYGON" case mdb.NetworkPlasma: return "PLASMA" + case mdb.NetworkArc: + return "ARC" default: return "EVM" } @@ -205,9 +207,24 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to log.Sugar.Warnf("[%s-WS] skip unconfigured contract %s", net, contract.Hex()) return } + tryProcessConfiguredEvmERC20Transfer(chainNetwork, *tokenConfig, toAddr, rawValue, txHash, blockTsMs) +} + +func TryProcessConfiguredEvmERC20Transfer(chainNetwork string, tokenConfig mdb.ChainToken, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) { + defer func() { + if err := recover(); err != nil { + log.Sugar.Errorf("[%s-WS] TryProcessConfiguredEvmERC20Transfer panic: %v", evmChainLogLabel(chainNetwork), err) + } + }() + + tryProcessConfiguredEvmERC20Transfer(chainNetwork, tokenConfig, toAddr, rawValue, txHash, blockTsMs) +} + +func tryProcessConfiguredEvmERC20Transfer(chainNetwork string, tokenConfig mdb.ChainToken, toAddr common.Address, rawValue *big.Int, txHash string, blockTsMs int64) { + net := evmChainLogLabel(chainNetwork) tokenSym := strings.ToUpper(strings.TrimSpace(tokenConfig.Symbol)) if tokenSym == "" { - log.Sugar.Warnf("[%s-WS] skip contract %s with empty token symbol", net, contract.Hex()) + log.Sugar.Warnf("[%s-WS] skip transfer with empty token symbol") return } walletAddr := strings.ToLower(toAddr.Hex()) @@ -257,6 +274,10 @@ func TryProcessEvmERC20Transfer(chainNetwork string, contract common.Address, to log.Sugar.Warnf("[%s-%s][%s] skip trade_id=%s token mismatch order=%s", net, tokenSym, walletAddr, tradeID, order.Token) return } + if blockTsMs > 0 && blockTsMs < order.CreatedAt.TimestampMilli() { + log.Sugar.Warnf("[%s-%s][%s] skip tx %s because block time %d is before order create time %d", net, tokenSym, walletAddr, txHash, blockTsMs, order.CreatedAt.TimestampMilli()) + return + } req := &request.OrderProcessingRequest{ ReceiveAddress: walletAddr, @@ -336,6 +357,8 @@ func networkDisplay(n string) string { return "Polygon" case mdb.NetworkPlasma: return "Plasma" + case mdb.NetworkArc: + return "Arc" default: if n == "" { return "Tron" diff --git a/src/task/listen.go b/src/task/listen.go index 96d14e7..2c9c955 100644 --- a/src/task/listen.go +++ b/src/task/listen.go @@ -13,6 +13,7 @@ func Start() { go StartBscWebSocketListener() go StartPolygonWebSocketListener() go StartPlasmaWebSocketListener() + go StartArcWebSocketListener() go StartTronBlockScannerListener() c := cron.New() diff --git a/src/task/listen_arc.go b/src/task/listen_arc.go new file mode 100644 index 0000000..1810d87 --- /dev/null +++ b/src/task/listen_arc.go @@ -0,0 +1,177 @@ +package task + +import ( + "context" + "math/big" + "strings" + "sync/atomic" + "time" + + "github.com/GMWalletApp/epusdt/model/data" + "github.com/GMWalletApp/epusdt/model/mdb" + "github.com/GMWalletApp/epusdt/model/service" + "github.com/GMWalletApp/epusdt/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" +) + +// This is the testnet for Arc USDC/EURC network. +const ArcEURCAddress = "0x89B50855Aa3bE2F677cD6303Cec089B5F319D72a" +const ArcEURCDecimals = 6 +const ArcUSDCERC20Address = "0x3600000000000000000000000000000000000000" +const ArcUSDCSystemAddress = "0x1800000000000000000000000000000000000000" +const ArcNativeUSDCDecimals = 18 +const ArcWs = "wss://rpc.testnet.arc.network" +const ArcChainID = "5042002" + +type arcRecipientSnapshot struct { + addrs map[string]struct{} +} + +var arcWatchedRecipients atomic.Pointer[arcRecipientSnapshot] + +var arcERC20Tokens = []mdb.ChainToken{ + {Network: mdb.NetworkArc, Symbol: "EURC", ContractAddress: ArcEURCAddress, Decimals: ArcEURCDecimals, Enabled: true}, +} + +var arcNativeUSDCTransferEventHash = common.HexToHash("0x62f084c00a442dcf51cdbb51beed2839bf42a268da8474b0e98f38edb7db5a22") + +func StartArcWebSocketListener() { + for { + runArcListener() + time.Sleep(10 * time.Second) + } +} + +func runArcListener() { + ctx := context.Background() + + wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkArc) + if err != nil { + log.Sugar.Errorf("[ARC-WS] Failed to get wallet addresses: %v", err) + return + } + storeArcRecipientsFromWallets(wallets) + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkArc) + if err != nil { + log.Sugar.Warnf("[ARC-WS] refresh wallet addresses: %v", err) + continue + } + storeArcRecipientsFromWallets(w) + } + } + }() + + contracts := make([]common.Address, 0, len(arcERC20Tokens)+1) + tokenByContract := make(map[string]mdb.ChainToken, len(arcERC20Tokens)+1) + for _, token := range arcERC20Tokens { + contract := common.HexToAddress(token.ContractAddress) + contracts = append(contracts, contract) + tokenByContract[strings.ToLower(contract.Hex())] = token + } + + // Arc USDC is a native gas token. Its single source of truth for balance + // movement logs is the native-token system contract, not the ERC-20 facade. + usdcSystemContract := common.HexToAddress(ArcUSDCSystemAddress) + contracts = append(contracts, usdcSystemContract) + tokenByContract[strings.ToLower(usdcSystemContract.Hex())] = mdb.ChainToken{ + Network: mdb.NetworkArc, + Symbol: "USDC", + ContractAddress: ArcUSDCERC20Address, + Decimals: ArcNativeUSDCDecimals, + Enabled: true, + } + + log.Sugar.Infof("[ARC-WS] connecting to %s chain_id=%s watching %d contract(s)", ArcWs, ArcChainID, len(contracts)) + + query := ethereum.FilterQuery{ + Addresses: contracts, + Topics: [][]common.Hash{{transferEventHash, arcNativeUSDCTransferEventHash}}, + } + + runEvmWsLogListener(ctx, "[ARC-WS]", ArcWs, query, func(client *ethclient.Client, vLog types.Log) { + handleArcTransferLog(client, tokenByContract, "[ARC-WS]", vLog) + }) +} + +func handleArcTransferLog(client *ethclient.Client, tokenByContract map[string]mdb.ChainToken, logPrefix string, vLog types.Log) { + if vLog.Removed { + return + } + if len(vLog.Topics) < 3 { + return + } + if !isArcTransferEvent(vLog) { + return + } + + toAddr := common.HexToAddress(vLog.Topics[2].Hex()) + if !isWatchedArcRecipient(toAddr) { + return + } + + token, ok := tokenByContract[strings.ToLower(vLog.Address.Hex())] + if !ok { + return + } + + amount := new(big.Int).SetBytes(vLog.Data) + log.Sugar.Infof("%s matched %s transfer contract=%s to=%s raw_amount=%s tx_hash=%s block=%d", logPrefix, token.Symbol, vLog.Address.Hex(), toAddr.Hex(), amount.String(), vLog.TxHash.Hex(), vLog.BlockNumber) + + var blockTsMs int64 + header, err := client.HeaderByNumber(context.Background(), new(big.Int).SetUint64(vLog.BlockNumber)) + if err != nil { + log.Sugar.Warnf("%s HeaderByNumber block=%d: %v, using local time", logPrefix, vLog.BlockNumber, err) + blockTsMs = time.Now().UnixMilli() + } else { + blockTsMs = int64(header.Time) * 1000 + } + + service.TryProcessConfiguredEvmERC20Transfer(mdb.NetworkArc, token, toAddr, amount, vLog.TxHash.Hex(), blockTsMs) +} + +func isArcTransferEvent(vLog types.Log) bool { + if len(vLog.Topics) == 0 { + return false + } + if strings.EqualFold(vLog.Address.Hex(), ArcUSDCSystemAddress) { + return vLog.Topics[0] == arcNativeUSDCTransferEventHash + } + if vLog.Topics[0] == transferEventHash { + return true + } + return false +} + +func storeArcRecipientsFromWallets(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{}{} + } + arcWatchedRecipients.Store(&arcRecipientSnapshot{addrs: m}) + return len(m) +} + +func isWatchedArcRecipient(to common.Address) bool { + snap := arcWatchedRecipients.Load() + if snap == nil || len(snap.addrs) == 0 { + return false + } + _, ok := snap.addrs[strings.ToLower(to.Hex())] + return ok +}