feat: add admin panel with multi-chain support and notification system

- Add full admin REST API: auth, API keys, chains, chain tokens, wallets,
  orders, RPC nodes, settings, dashboard stats, notifications
- Add JWT-based admin authentication and API key auth middleware
- Add multi-chain listeners: BSC, ETH, Polygon, Plasma, EVM WebSocket
- Add RPC node health check job with automatic failover
- Add Telegram notification channel for order events
- Add installer for first-run database initialization
- Add new DB models: admin_user, api_key, chain, chain_token,
  rpc_node, settings, notification_channel
- Add order statistics aggregation (daily/monthly/by-chain)
- Serve built admin SPA from embedded www/ assets
- Add comprehensive test coverage for all new features
This commit is contained in:
line-6000
2026-04-22 02:28:31 +08:00
parent 097c716714
commit 6bb47d4b00
247 changed files with 9933 additions and 1279 deletions
+11 -2
View File
@@ -7,26 +7,35 @@ import (
func Start() {
log.Sugar.Info("[task] Starting task scheduler...")
// The ETH listener short-circuits internally when chain is disabled
// or no tokens are configured, so always launch the goroutine.
go StartEthereumWebSocketListener()
go StartBscWebSocketListener()
go StartPolygonWebSocketListener()
go StartPlasmaWebSocketListener()
c := cron.New()
// trc20钱包监听
// TRC20 polling
_, err := c.AddJob("@every 5s", ListenTrc20Job{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add ListenTrc20Job: %v", err)
return
}
log.Sugar.Info("[task] ListenTrc20Job scheduled successfully (@every 5s)")
// solana钱包监听
// Solana polling
_, err = c.AddJob("@every 5s", ListenSolJob{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add ListenSolJob: %v", err)
return
}
log.Sugar.Info("[task] ListenSolJob scheduled successfully (@every 5s)")
// RPC node health checks
_, err = c.AddJob("@every 30s", RpcHealthJob{})
if err != nil {
log.Sugar.Errorf("[task] Failed to add RpcHealthJob: %v", err)
return
}
log.Sugar.Info("[task] RpcHealthJob scheduled successfully (@every 30s)")
c.Start()
log.Sugar.Info("[task] Task scheduler started")
}
+36 -18
View File
@@ -18,11 +18,7 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
)
var (
// USDT / USDC 合约地址(BSC 主网)
bscUsdtContract = common.HexToAddress("0x55d398326f99059fF775485246999027B3197955")
bscUsdcContract = common.HexToAddress("0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d")
)
const bscDefaultWsURL = "wss://bsc.drpc.org"
type bscRecipientSnapshot struct {
addrs map[string]struct{}
@@ -30,7 +26,24 @@ type bscRecipientSnapshot struct {
var bscWatchedRecipients atomic.Pointer[bscRecipientSnapshot]
// StartBscWebSocketListener drives the BSC listener. Checks chain
// enable status and reloads contract addresses from chain_tokens every
// 10s so admin-side toggles take effect without a restart.
func StartBscWebSocketListener() {
for {
if data.IsChainEnabled(mdb.NetworkBsc) {
if contracts := loadChainTokenContracts(mdb.NetworkBsc, "[BSC-WS]"); len(contracts) > 0 {
runBscListener(contracts)
}
}
time.Sleep(10 * time.Second)
}
}
func runBscListener(contracts []common.Address) {
ctx, cancel := chainEnabledWatchdog(mdb.NetworkBsc, "[BSC-WS]", chainTokenFingerprint(mdb.NetworkBsc))
defer cancel()
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkBsc)
if err != nil {
log.Sugar.Errorf("[BSC-WS] Failed to get wallet addresses: %v", err)
@@ -40,25 +53,30 @@ func StartBscWebSocketListener() {
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
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkBsc)
if err != nil {
log.Sugar.Warnf("[BSC-WS] refresh wallet addresses: %v", err)
continue
}
storeBscRecipientsFromWallets(w)
}
storeBscRecipientsFromWallets(w)
}
}()
wsURL := "wss://bsc.drpc.org"
wsURL := resolveChainWsURL(mdb.NetworkBsc, bscDefaultWsURL)
log.Sugar.Infof("[BSC-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{
Addresses: []common.Address{
bscUsdtContract,
bscUsdcContract,
},
Topics: [][]common.Hash{},
Addresses: contracts,
Topics: [][]common.Hash{},
}
runEvmWsLogListener("[BSC-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
runEvmWsLogListener(ctx, "[BSC-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 {
return
}
+101
View File
@@ -0,0 +1,101 @@
package task
import (
"context"
"sort"
"strings"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
"github.com/ethereum/go-ethereum/common"
)
// chainEnabledWatchdog returns a cancellable context whose cancel() is
// invoked when either:
// 1. IsChainEnabled(network) returns false — admin disabled the chain
// 2. The enabled-token fingerprint changes — admin added/removed/
// toggled a chain_tokens row for this network
//
// Both cases need the listener to exit so the outer loop can reconnect
// with the fresh token set (EVM WebSocket subscriptions are fixed at
// connect time; to pick up a new contract we must re-subscribe).
//
// initialFingerprint is the fingerprint computed BEFORE connecting; the
// watchdog compares every 10s tick against this baseline. Caller must
// defer the returned cancel func to release the goroutine.
func chainEnabledWatchdog(network, logPrefix, initialFingerprint string) (context.Context, context.CancelFunc) {
ctx, cancel := context.WithCancel(context.Background())
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if !data.IsChainEnabled(network) {
log.Sugar.Infof("%s chain disabled, stopping listener", logPrefix)
cancel()
return
}
if fp := chainTokenFingerprint(network); fp != initialFingerprint {
log.Sugar.Infof("%s chain_tokens changed (was %q → now %q), reconnecting", logPrefix, initialFingerprint, fp)
cancel()
return
}
}
}
}()
return ctx, cancel
}
// chainTokenFingerprint returns a stable string representing the
// enabled-token set for a network. Used by chainEnabledWatchdog to
// detect admin changes between polls.
func chainTokenFingerprint(network string) string {
tokens, err := data.ListEnabledChainTokensByNetwork(network)
if err != nil {
return ""
}
parts := make([]string, 0, len(tokens))
for _, t := range tokens {
parts = append(parts, strings.ToLower(strings.TrimSpace(t.ContractAddress))+"|"+strings.ToUpper(strings.TrimSpace(t.Symbol)))
}
sort.Strings(parts)
return strings.Join(parts, ",")
}
// loadChainTokenContracts reads enabled tokens for a network and returns
// their contract addresses as ethereum-go common.Address values. Rows
// with blank contract_address (e.g. Solana native SOL marker) are
// skipped. Callers use the length to decide whether to connect or idle.
func loadChainTokenContracts(network, logPrefix string) []common.Address {
tokens, err := data.ListEnabledChainTokensByNetwork(network)
if err != nil {
log.Sugar.Errorf("%s load chain_tokens err=%v", logPrefix, err)
return nil
}
addrs := make([]common.Address, 0, len(tokens))
for _, t := range tokens {
c := strings.TrimSpace(t.ContractAddress)
if c == "" {
continue
}
addrs = append(addrs, common.HexToAddress(c))
}
return addrs
}
// resolveChainWsURL picks a healthy WS endpoint from rpc_nodes for the
// given network, falling back to the provided default (usually the
// public node URL) when no row is configured.
func resolveChainWsURL(network, fallback string) string {
node, err := data.SelectRpcNode(network, mdb.RpcNodeTypeWs)
if err == nil && node != nil && node.ID > 0 {
return node.Url
}
return fallback
}
+55 -39
View File
@@ -7,6 +7,7 @@ import (
"sync/atomic"
"time"
"github.com/assimon/luuu/config"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/model/service"
@@ -18,15 +19,24 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
)
var (
// Transfer 事件签名 — ERC-20 signature, same on every EVM chain.
var transferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
// USDT / USDC 合约地址(ETH 主网)
usdtContract = common.HexToAddress("0xdAC17F958D2ee523a2206206994597C13D831ec7")
usdcContract = common.HexToAddress("0xA0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
const ethereumDefaultWsURL = "wss://ethereum.publicnode.com"
// Transfer 事件签名
transferEventHash = common.HexToHash("0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef")
)
// resolveEthereumWsURL picks a healthy WS endpoint. Falls back to the
// legacy .env value (if set) before the shared default — keeps existing
// deployments working until the admin adds an rpc_nodes row.
func resolveEthereumWsURL() string {
node, err := data.SelectRpcNode(mdb.NetworkEthereum, mdb.RpcNodeTypeWs)
if err == nil && node != nil && node.ID > 0 {
return node.Url
}
if u := config.GetEthereumWsUrl(); u != "" {
return u
}
return ethereumDefaultWsURL
}
type ethRecipientSnapshot struct {
addrs map[string]struct{}
@@ -35,47 +45,68 @@ type ethRecipientSnapshot struct {
var ethWatchedRecipients atomic.Pointer[ethRecipientSnapshot]
func StartEthereumWebSocketListener() {
// Wait until the chain is enabled AND at least one token contract
// is configured. Polls every 10s so admin-side toggles kick in
// without a restart. Once conditions are met we proceed to connect;
// if the websocket later drops we exit the loop and rely on the
// process-level restart to reconnect (same as before this refactor).
for {
if data.IsChainEnabled(mdb.NetworkEthereum) {
if contracts := loadChainTokenContracts(mdb.NetworkEthereum, "[ETH-WS]"); len(contracts) > 0 {
runEthereumListener(contracts)
}
}
time.Sleep(10 * time.Second)
}
}
func runEthereumListener(contracts []common.Address) {
ctx, cancel := chainEnabledWatchdog(mdb.NetworkEthereum, "[ETH-WS]", chainTokenFingerprint(mdb.NetworkEthereum))
defer cancel()
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum)
if err != nil {
log.Sugar.Errorf("Failed to get wallet addresses: %v", err)
log.Sugar.Errorf("[ETH-WS] Failed to get wallet addresses: %v", err)
return
}
StoreEthRecipientsFromWallets(wallets)
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum)
if err != nil {
log.Sugar.Warnf("[ETH-WS] refresh wallet addresses: %v", err)
continue
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkEthereum)
if err != nil {
log.Sugar.Warnf("[ETH-WS] refresh wallet addresses: %v", err)
continue
}
StoreEthRecipientsFromWallets(w)
}
StoreEthRecipientsFromWallets(w)
}
}()
wsURL := "wss://ethereum.publicnode.com"
wsURL := resolveEthereumWsURL()
log.Sugar.Infof("[ETH-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{
Addresses: []common.Address{
usdtContract,
usdcContract,
},
Topics: [][]common.Hash{},
Addresses: contracts,
Topics: [][]common.Hash{},
}
runEvmWsLogListener("[ETH-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
runEvmWsLogListener(ctx, "[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
}
@@ -115,18 +146,3 @@ func isWatchedEthRecipient(to common.Address) bool {
return ok
}
func formatAmount(amount *big.Int, decimals int) string {
f := new(big.Float).SetInt(amount)
divisor := new(big.Float).SetFloat64(float64Pow(10, decimals))
result := new(big.Float).Quo(f, divisor)
return result.Text('f', 6)
}
func float64Pow(a, b int) float64 {
result := 1.0
for i := 0; i < b; i++ {
result *= float64(a)
}
return result
}
+43 -8
View File
@@ -11,7 +11,12 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
)
func runEvmWsLogListener(logPrefix, wsURL string, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
// runEvmWsLogListener connects to wsURL, subscribes to Transfer logs,
// and dispatches each log to handleLog. It retries on transient errors
// with exponential backoff. The ctx lets the caller trigger a clean
// exit — e.g. when admin disables the chain, the caller cancels the
// context and the function returns instead of reconnecting forever.
func runEvmWsLogListener(ctx context.Context, logPrefix, wsURL string, query ethereum.FilterQuery, handleLog func(*ethclient.Client, types.Log)) {
const (
minBackoff = 2 * time.Second
maxBackoff = 60 * time.Second
@@ -20,34 +25,47 @@ func runEvmWsLogListener(logPrefix, wsURL string, query ethereum.FilterQuery, ha
failWait := minBackoff
for {
if ctx.Err() != nil {
return
}
client, err := ethclient.Dial(wsURL)
if err != nil {
log.Sugar.Warnf("%s dial: %v, retry in %s", logPrefix, err, failWait)
time.Sleep(failWait)
if !sleepOrDone(ctx, failWait) {
return
}
failWait = nextBackoff(failWait, maxBackoff)
continue
}
logsCh := make(chan types.Log)
sub, err := client.SubscribeFilterLogs(context.Background(), query, logsCh)
sub, err := client.SubscribeFilterLogs(ctx, query, logsCh)
if err != nil {
client.Close()
log.Sugar.Warnf("%s subscribe: %v, retry in %s", logPrefix, err, failWait)
time.Sleep(failWait)
if !sleepOrDone(ctx, failWait) {
return
}
failWait = nextBackoff(failWait, maxBackoff)
continue
}
failWait = minBackoff
log.Sugar.Infof("%s connected, subscribed to USDT/USDC Transfer logs", logPrefix)
log.Sugar.Infof("%s connected, subscribed to Transfer logs", logPrefix)
recvLoop(client, sub, logsCh, logPrefix, handleLog)
recvLoop(ctx, client, sub, logsCh, logPrefix, handleLog)
time.Sleep(rejoinWait)
if ctx.Err() != nil {
return
}
if !sleepOrDone(ctx, rejoinWait) {
return
}
}
}
func recvLoop(client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, logPrefix string, handleLog func(*ethclient.Client, types.Log)) {
func recvLoop(ctx context.Context, client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan types.Log, logPrefix string, handleLog func(*ethclient.Client, types.Log)) {
defer func() {
sub.Unsubscribe()
client.Close()
@@ -55,6 +73,9 @@ func recvLoop(client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan
for {
select {
case <-ctx.Done():
log.Sugar.Infof("%s context cancelled, stopping", logPrefix)
return
case err := <-sub.Err():
if err != nil {
log.Sugar.Warnf("%s subscription error: %v, reconnecting", logPrefix, err)
@@ -72,6 +93,20 @@ func recvLoop(client *ethclient.Client, sub ethereum.Subscription, logsCh <-chan
}
}
// sleepOrDone waits for d or for ctx cancellation, whichever comes
// first. Returns true if the sleep completed normally, false if ctx
// was cancelled (caller should exit).
func sleepOrDone(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
func nextBackoff(cur, max time.Duration) time.Duration {
n := cur * 2
if n > max {
+34 -13
View File
@@ -18,9 +18,7 @@ import (
"github.com/ethereum/go-ethereum/ethclient"
)
var (
plasmaUsdt0Contract = common.HexToAddress("0xB8CE59FC3717ada4C02eaDF9682A9e934F625ebb")
)
const plasmaDefaultWsURL = "wss://rpc.plasma.to"
type plasmaRecipientSnapshot struct {
addrs map[string]struct{}
@@ -28,7 +26,23 @@ type plasmaRecipientSnapshot struct {
var plasmaWatchedRecipients atomic.Pointer[plasmaRecipientSnapshot]
// StartPlasmaWebSocketListener drives the Plasma listener with dynamic
// chain/token config reload every 10s.
func StartPlasmaWebSocketListener() {
for {
if data.IsChainEnabled(mdb.NetworkPlasma) {
if contracts := loadChainTokenContracts(mdb.NetworkPlasma, "[PLASMA-WS]"); len(contracts) > 0 {
runPlasmaListener(contracts)
}
}
time.Sleep(10 * time.Second)
}
}
func runPlasmaListener(contracts []common.Address) {
ctx, cancel := chainEnabledWatchdog(mdb.NetworkPlasma, "[PLASMA-WS]", chainTokenFingerprint(mdb.NetworkPlasma))
defer cancel()
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPlasma)
if err != nil {
log.Sugar.Errorf("[PLASMA-WS] Failed to get wallet addresses: %v", err)
@@ -38,23 +52,30 @@ func StartPlasmaWebSocketListener() {
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
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPlasma)
if err != nil {
log.Sugar.Warnf("[PLASMA-WS] refresh wallet addresses: %v", err)
continue
}
storePlasmaRecipientsFromWallets(w)
}
storePlasmaRecipientsFromWallets(w)
}
}()
// 文档提供 https://rpc.plasma.to;常见实现同主机 wss
wsURL := "wss://rpc.plasma.to"
wsURL := resolveChainWsURL(mdb.NetworkPlasma, plasmaDefaultWsURL)
log.Sugar.Infof("[PLASMA-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{
Addresses: []common.Address{plasmaUsdt0Contract},
Addresses: contracts,
Topics: [][]common.Hash{},
}
runEvmWsLogListener("[PLASMA-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
runEvmWsLogListener(ctx, "[PLASMA-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 {
return
}
+35 -20
View File
@@ -18,12 +18,7 @@ import (
"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")
)
const polygonDefaultWsURL = "wss://polygon-bor-rpc.publicnode.com"
type polygonRecipientSnapshot struct {
addrs map[string]struct{}
@@ -31,7 +26,23 @@ type polygonRecipientSnapshot struct {
var polygonWatchedRecipients atomic.Pointer[polygonRecipientSnapshot]
// StartPolygonWebSocketListener drives the Polygon listener with
// dynamic chain/token config reload every 10s.
func StartPolygonWebSocketListener() {
for {
if data.IsChainEnabled(mdb.NetworkPolygon) {
if contracts := loadChainTokenContracts(mdb.NetworkPolygon, "[POLYGON-WS]"); len(contracts) > 0 {
runPolygonListener(contracts)
}
}
time.Sleep(10 * time.Second)
}
}
func runPolygonListener(contracts []common.Address) {
ctx, cancel := chainEnabledWatchdog(mdb.NetworkPolygon, "[POLYGON-WS]", chainTokenFingerprint(mdb.NetworkPolygon))
defer cancel()
wallets, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPolygon)
if err != nil {
log.Sugar.Errorf("[POLYGON-WS] Failed to get wallet addresses: %v", err)
@@ -41,26 +52,30 @@ func StartPolygonWebSocketListener() {
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
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
w, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkPolygon)
if err != nil {
log.Sugar.Warnf("[POLYGON-WS] refresh wallet addresses: %v", err)
continue
}
storePolygonRecipientsFromWallets(w)
}
storePolygonRecipientsFromWallets(w)
}
}()
wsURL := "wss://polygon-bor-rpc.publicnode.com"
wsURL := resolveChainWsURL(mdb.NetworkPolygon, polygonDefaultWsURL)
log.Sugar.Infof("[POLYGON-WS] connecting to %s watching %d contract(s)", wsURL, len(contracts))
query := ethereum.FilterQuery{
Addresses: []common.Address{
polygonUsdtContract,
polygonUsdcContract,
polygonUsdcEContract,
},
Topics: [][]common.Hash{},
Addresses: contracts,
Topics: [][]common.Hash{},
}
runEvmWsLogListener("[POLYGON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
runEvmWsLogListener(ctx, "[POLYGON-WS]", wsURL, query, func(client *ethclient.Client, vLog types.Log) {
if len(vLog.Topics) < 3 {
return
}
+4
View File
@@ -17,6 +17,10 @@ func (r ListenSolJob) Run() {
gListenSolJobLock.Lock()
defer gListenSolJobLock.Unlock()
log.Sugar.Debug("[ListenSolJob] Job triggered")
if !data.IsChainEnabled(mdb.NetworkSolana) {
log.Sugar.Debug("[ListenSolJob] chain disabled, skipping")
return
}
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkSolana)
if err != nil {
log.Sugar.Errorf("[ListenSolJob] Failed to get wallet addresses: %v", err)
+4
View File
@@ -17,6 +17,10 @@ func (r ListenTrc20Job) Run() {
gListenTrc20JobLock.Lock()
defer gListenTrc20JobLock.Unlock()
log.Sugar.Debug("[ListenTrc20Job] Job triggered")
if !data.IsChainEnabled(mdb.NetworkTron) {
log.Sugar.Debug("[ListenTrc20Job] chain disabled, skipping")
return
}
walletAddress, err := data.GetAvailableWalletAddressByNetwork(mdb.NetworkTron)
if err != nil {
log.Sugar.Errorf("[ListenTrc20Job] Failed to get wallet addresses: %v", err)
+97
View File
@@ -0,0 +1,97 @@
package task
import (
"net"
"net/url"
"strings"
"sync"
"time"
"github.com/assimon/luuu/model/data"
"github.com/assimon/luuu/model/mdb"
"github.com/assimon/luuu/util/log"
)
const rpcProbeTimeout = 5 * time.Second
// RpcHealthJob periodically probes every enabled rpc_nodes row and
// writes status/last_latency_ms. Results drive SelectRpcNode weighted
// picking at runtime.
type RpcHealthJob struct{}
var gRpcHealthJobLock sync.Mutex
func (r RpcHealthJob) Run() {
gRpcHealthJobLock.Lock()
defer gRpcHealthJobLock.Unlock()
nodes, err := data.ListRpcNodes("")
if err != nil {
log.Sugar.Errorf("[rpc-health] list nodes err=%v", err)
return
}
var wg sync.WaitGroup
for i := range nodes {
if !nodes[i].Enabled {
continue
}
wg.Add(1)
go func(n mdb.RpcNode) {
defer wg.Done()
status, latency := ProbeNode(n.Url)
if err := data.UpdateRpcNodeHealth(n.ID, status, latency); err != nil {
log.Sugar.Warnf("[rpc-health] update node %d err=%v", n.ID, err)
}
}(nodes[i])
}
wg.Wait()
}
// ProbeNode does a TCP dial to the RPC URL and returns (status, latencyMs).
// Exported so the admin controller can reuse it without duplicating logic.
func ProbeNode(rawURL string) (string, int) {
addr, err := ParseAddress(rawURL)
if err != nil {
return mdb.RpcNodeStatusDown, -1
}
dur, err := MeasureTCPDial(addr, rpcProbeTimeout)
if err != nil {
return mdb.RpcNodeStatusDown, -1
}
return mdb.RpcNodeStatusOk, int(dur.Milliseconds())
}
func ParseAddress(raw string) (string, error) {
if !strings.Contains(raw, "://") {
raw = "tcp://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return "", err
}
host := u.Hostname()
port := u.Port()
if port == "" {
switch u.Scheme {
case "https", "wss":
port = "443"
default:
port = "80"
}
}
return host + ":" + port, nil
}
func MeasureTCPDial(addr string, timeout time.Duration) (time.Duration, error) {
start := time.Now()
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return 0, err
}
defer conn.Close()
return time.Since(start), nil
}
+194
View File
@@ -0,0 +1,194 @@
package task
import (
"net"
"strconv"
"testing"
"time"
"github.com/assimon/luuu/model/mdb"
)
// --------------- ParseAddress ---------------
func TestParseAddress_HttpWithPort(t *testing.T) {
got, err := ParseAddress("http://api.trongrid.io:8090")
if err != nil {
t.Fatal(err)
}
if got != "api.trongrid.io:8090" {
t.Fatalf("want api.trongrid.io:8090, got %s", got)
}
}
func TestParseAddress_HttpDefaultPort(t *testing.T) {
got, err := ParseAddress("http://api.trongrid.io")
if err != nil {
t.Fatal(err)
}
if got != "api.trongrid.io:80" {
t.Fatalf("want api.trongrid.io:80, got %s", got)
}
}
func TestParseAddress_HttpsDefaultPort(t *testing.T) {
got, err := ParseAddress("https://api.trongrid.io")
if err != nil {
t.Fatal(err)
}
if got != "api.trongrid.io:443" {
t.Fatalf("want api.trongrid.io:443, got %s", got)
}
}
func TestParseAddress_WssDefaultPort(t *testing.T) {
got, err := ParseAddress("wss://bsc-ws-node.nariox.org")
if err != nil {
t.Fatal(err)
}
if got != "bsc-ws-node.nariox.org:443" {
t.Fatalf("want bsc-ws-node.nariox.org:443, got %s", got)
}
}
func TestParseAddress_WsDefaultPort(t *testing.T) {
got, err := ParseAddress("ws://localhost")
if err != nil {
t.Fatal(err)
}
if got != "localhost:80" {
t.Fatalf("want localhost:80, got %s", got)
}
}
func TestParseAddress_WsWithPort(t *testing.T) {
got, err := ParseAddress("ws://localhost:9650")
if err != nil {
t.Fatal(err)
}
if got != "localhost:9650" {
t.Fatalf("want localhost:9650, got %s", got)
}
}
func TestParseAddress_BareHostPort(t *testing.T) {
got, err := ParseAddress("10.0.0.1:8545")
if err != nil {
t.Fatal(err)
}
if got != "10.0.0.1:8545" {
t.Fatalf("want 10.0.0.1:8545, got %s", got)
}
}
func TestParseAddress_BareHostNoPort(t *testing.T) {
got, err := ParseAddress("example.com")
if err != nil {
t.Fatal(err)
}
if got != "example.com:80" {
t.Fatalf("want example.com:80, got %s", got)
}
}
func TestParseAddress_WithPath(t *testing.T) {
got, err := ParseAddress("https://mainnet.infura.io/v3/KEY123")
if err != nil {
t.Fatal(err)
}
if got != "mainnet.infura.io:443" {
t.Fatalf("want mainnet.infura.io:443, got %s", got)
}
}
// --------------- MeasureTCPDial ---------------
func TestMeasureTCPDial_Success(t *testing.T) {
// start a local TCP listener
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
dur, err := MeasureTCPDial(ln.Addr().String(), 2*time.Second)
if err != nil {
t.Fatalf("dial should succeed: %v", err)
}
if dur <= 0 {
t.Fatalf("duration should be positive, got %v", dur)
}
}
func TestMeasureTCPDial_Refused(t *testing.T) {
// pick a port that is almost certainly not listening
_, err := MeasureTCPDial("127.0.0.1:1", 500*time.Millisecond)
if err == nil {
t.Fatal("dial to closed port should fail")
}
}
func TestMeasureTCPDial_RespectsTimeout(t *testing.T) {
// Start a listener but never accept — the dial handshake will hang
// until the timeout fires.
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
// Set backlog to 0 by not calling Accept and filling the queue.
// Connect once to fill the backlog, then the next dial should stall.
conn, err := net.Dial("tcp", ln.Addr().String())
if err != nil {
t.Skip("could not saturate backlog, skipping")
}
defer conn.Close()
// Use a very short timeout to verify it doesn't hang forever.
_, dialErr := MeasureTCPDial(ln.Addr().String(), 100*time.Millisecond)
// This may succeed (kernel allows queued connections) or timeout — both
// are acceptable. We just verify it returns within a sane window.
_ = dialErr
}
// --------------- ProbeNode ---------------
func TestProbeNode_Reachable(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer ln.Close()
port := ln.Addr().(*net.TCPAddr).Port
url := "http://127.0.0.1:" + strconv.Itoa(port)
status, latency := ProbeNode(url)
if status != mdb.RpcNodeStatusOk {
t.Fatalf("want ok, got %s", status)
}
if latency < 0 {
t.Fatalf("latency should be >= 0, got %d", latency)
}
}
func TestProbeNode_Unreachable(t *testing.T) {
status, latency := ProbeNode("http://127.0.0.1:1")
if status != mdb.RpcNodeStatusDown {
t.Fatalf("want down, got %s", status)
}
if latency != -1 {
t.Fatalf("want -1, got %d", latency)
}
}
func TestProbeNode_InvalidURL(t *testing.T) {
status, latency := ProbeNode("://bad")
if status != mdb.RpcNodeStatusDown {
t.Fatalf("want down, got %s", status)
}
if latency != -1 {
t.Fatalf("want -1, got %d", latency)
}
}