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:
+11
-2
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user