mirror of
https://github.com/luxfi/vm.git
synced 2026-10-30 15:13:40 +00:00
Six packages would not build, each from a migration that stopped one step short, and a package that does not compile reports nothing at all. The generated mocks had fallen behind their interfaces: Manager kept four of its eight methods, Server was missing SetRootInfoProvider, and there was no Factory mock under the name the tests used. All three are regenerated, and each package now carries the go:generate line that produces them, so a method added to an interface is a regeneration away rather than a puzzle in another package's tests. The interfaces had also taken a context that the expectations had not, so forty-six of them name it now. Aliases and the level test followed their types: the standalone atomic package rather than the compatibility re-export, and log's own Level constants beside the field that holds one. One table field was renamed in every place but the line that read it. Two services lost a behaviour along the way. Registration aliases every VM to its own id, and both GetVMs and LoadVMs handed that back as an alias of itself, which the tests had said all along they should not; and both read their context from a request the tests passed as nil, which segfaulted before a single expectation could run. chains/atomic still fails, deliberately: its fixture asserts a big-endian wire layout "so any on-disk record from the legacy build still parses", and luxfi/util's packer moved to little-endian on purpose. Which side gives is a question about records already on disk, not about this test. Co-authored-by: Hanzo Dev <[email protected]>
623 lines
16 KiB
Go
623 lines
16 KiB
Go
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package admin
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"runtime/debug"
|
|
"sync"
|
|
|
|
"github.com/gorilla/rpc/v2"
|
|
|
|
"github.com/luxfi/constants"
|
|
"github.com/luxfi/database"
|
|
"github.com/luxfi/filesystem/perms"
|
|
"github.com/luxfi/formatting"
|
|
"github.com/luxfi/ids"
|
|
"github.com/luxfi/log"
|
|
"github.com/luxfi/math/set"
|
|
"github.com/luxfi/metric/profiler"
|
|
"github.com/luxfi/util/json"
|
|
"github.com/luxfi/vm/api"
|
|
"github.com/luxfi/vm/api/server"
|
|
"github.com/luxfi/vm/manager"
|
|
"github.com/luxfi/vm/registry"
|
|
)
|
|
|
|
const (
|
|
maxAliasLength = 512
|
|
|
|
// Name of file that stacktraces are written to
|
|
stacktraceFile = "stacktrace.txt"
|
|
)
|
|
|
|
var (
|
|
errAliasTooLong = errors.New("alias length is too long")
|
|
errNoLogLevel = errors.New("need to specify either displayLevel or logLevel")
|
|
)
|
|
|
|
// ChainTracker is the interface for tracking chains at runtime
|
|
type ChainTracker interface {
|
|
TrackChain(chainID ids.ID) error
|
|
TrackedChains() set.Set[ids.ID]
|
|
}
|
|
|
|
type Config struct {
|
|
Log log.Logger
|
|
ProfileDir string
|
|
LogFactory log.Factory
|
|
NodeConfig interface{}
|
|
DB database.Database
|
|
ChainManager manager.ChainManager
|
|
HTTPServer server.PathAdderWithReadLock
|
|
VMRegistry registry.VMRegistry
|
|
VMManager manager.Manager
|
|
PluginDir string
|
|
Network ChainTracker
|
|
}
|
|
|
|
// Admin is the API service for node admin management
|
|
type Admin struct {
|
|
Config
|
|
lock sync.RWMutex
|
|
profiler profiler.Profiler
|
|
}
|
|
|
|
// NewService returns a new admin API service.
|
|
// All of the fields in [config] must be set.
|
|
func NewService(config Config) (http.Handler, error) {
|
|
server := rpc.NewServer()
|
|
codec := json.NewCodec()
|
|
server.RegisterCodec(codec, "application/json")
|
|
server.RegisterCodec(codec, "application/json;charset=UTF-8")
|
|
return server, server.RegisterService(
|
|
&Admin{
|
|
Config: config,
|
|
profiler: profiler.New(config.ProfileDir),
|
|
},
|
|
"admin",
|
|
)
|
|
}
|
|
|
|
// StartCPUProfiler starts a cpu profile writing to the specified file
|
|
func (a *Admin) StartCPUProfiler(_ *http.Request, _ *struct{}, _ *api.EmptyReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "startCPUProfiler"),
|
|
)
|
|
|
|
a.lock.Lock()
|
|
defer a.lock.Unlock()
|
|
|
|
return a.profiler.StartCPUProfiler()
|
|
}
|
|
|
|
// StopCPUProfiler stops the cpu profile
|
|
func (a *Admin) StopCPUProfiler(_ *http.Request, _ *struct{}, _ *api.EmptyReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "stopCPUProfiler"),
|
|
)
|
|
|
|
a.lock.Lock()
|
|
defer a.lock.Unlock()
|
|
|
|
return a.profiler.StopCPUProfiler()
|
|
}
|
|
|
|
// MemoryProfile runs a memory profile writing to the specified file
|
|
func (a *Admin) MemoryProfile(_ *http.Request, _ *struct{}, _ *api.EmptyReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "memoryProfile"),
|
|
)
|
|
|
|
a.lock.Lock()
|
|
defer a.lock.Unlock()
|
|
|
|
return a.profiler.MemoryProfile()
|
|
}
|
|
|
|
// LockProfile runs a mutex profile writing to the specified file
|
|
func (a *Admin) LockProfile(_ *http.Request, _ *struct{}, _ *api.EmptyReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "lockProfile"),
|
|
)
|
|
|
|
a.lock.Lock()
|
|
defer a.lock.Unlock()
|
|
|
|
return a.profiler.LockProfile()
|
|
}
|
|
|
|
// AliasArgs are the arguments for calling Alias
|
|
type AliasArgs struct {
|
|
Endpoint string `json:"endpoint"`
|
|
Alias string `json:"alias"`
|
|
}
|
|
|
|
// Alias attempts to alias an HTTP endpoint to a new name
|
|
func (a *Admin) Alias(_ *http.Request, args *AliasArgs, _ *api.EmptyReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "alias"),
|
|
log.String("endpoint", args.Endpoint),
|
|
log.String("alias", args.Alias),
|
|
)
|
|
|
|
if len(args.Alias) > maxAliasLength {
|
|
return errAliasTooLong
|
|
}
|
|
|
|
return a.HTTPServer.AddAliasesWithReadLock(args.Endpoint, args.Alias)
|
|
}
|
|
|
|
// AliasChainArgs are the arguments for calling AliasChain
|
|
type AliasChainArgs struct {
|
|
Chain string `json:"chain"`
|
|
Alias string `json:"alias"`
|
|
}
|
|
|
|
// AliasChain attempts to alias a chain to a new name
|
|
func (a *Admin) AliasChain(_ *http.Request, args *AliasChainArgs, _ *api.EmptyReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "aliasChain"),
|
|
log.String("chain", args.Chain),
|
|
log.String("alias", args.Alias),
|
|
)
|
|
|
|
if len(args.Alias) > maxAliasLength {
|
|
return errAliasTooLong
|
|
}
|
|
chainID, err := a.ChainManager.Lookup(args.Chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
a.lock.Lock()
|
|
defer a.lock.Unlock()
|
|
|
|
if err := a.ChainManager.Alias(chainID, args.Alias); err != nil {
|
|
return err
|
|
}
|
|
|
|
endpoint := path.Join(constants.ChainAliasPrefix, chainID.String())
|
|
alias := path.Join(constants.ChainAliasPrefix, args.Alias)
|
|
return a.HTTPServer.AddAliasesWithReadLock(endpoint, alias)
|
|
}
|
|
|
|
// GetChainAliasesArgs are the arguments for calling GetChainAliases
|
|
type GetChainAliasesArgs struct {
|
|
Chain string `json:"chain"`
|
|
}
|
|
|
|
// GetChainAliasesReply are the aliases of the given chain
|
|
type GetChainAliasesReply struct {
|
|
Aliases []string `json:"aliases"`
|
|
}
|
|
|
|
// GetChainAliases returns the aliases of the chain
|
|
func (a *Admin) GetChainAliases(_ *http.Request, args *GetChainAliasesArgs, reply *GetChainAliasesReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "getChainAliases"),
|
|
log.String("chain", args.Chain),
|
|
)
|
|
|
|
id, err := ids.FromString(args.Chain)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
reply.Aliases, err = a.ChainManager.Aliases(id)
|
|
return err
|
|
}
|
|
|
|
// Stacktrace returns the current global stacktrace
|
|
func (a *Admin) Stacktrace(_ *http.Request, _ *struct{}, _ *api.EmptyReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "stacktrace"),
|
|
)
|
|
|
|
stacktrace := debug.Stack()
|
|
|
|
a.lock.Lock()
|
|
defer a.lock.Unlock()
|
|
|
|
return perms.WriteFile(stacktraceFile, stacktrace, perms.ReadWrite)
|
|
}
|
|
|
|
type SetLoggerLevelArgs struct {
|
|
LoggerName string `json:"loggerName"`
|
|
LogLevel *log.Level `json:"logLevel"`
|
|
DisplayLevel *log.Level `json:"displayLevel"`
|
|
}
|
|
|
|
type LogAndDisplayLevels struct {
|
|
LogLevel log.Level `json:"logLevel"`
|
|
DisplayLevel log.Level `json:"displayLevel"`
|
|
}
|
|
|
|
type LoggerLevelReply struct {
|
|
LoggerLevels map[string]LogAndDisplayLevels `json:"loggerLevels"`
|
|
}
|
|
|
|
// SetLoggerLevel sets the log level and/or display level for loggers.
|
|
// If len([args.LoggerName]) == 0, sets the log/display level of all loggers.
|
|
// Otherwise, sets the log/display level of the loggers named in that argument.
|
|
// Sets the log level of these loggers to args.LogLevel.
|
|
// If args.LogLevel == nil, doesn't set the log level of these loggers.
|
|
// If args.LogLevel != nil, must be a valid string representation of a log level.
|
|
// Sets the display level of these loggers to args.LogLevel.
|
|
// If args.DisplayLevel == nil, doesn't set the display level of these loggers.
|
|
// If args.DisplayLevel != nil, must be a valid string representation of a log level.
|
|
func (a *Admin) SetLoggerLevel(_ *http.Request, args *SetLoggerLevelArgs, reply *LoggerLevelReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "setLoggerLevel"),
|
|
log.String("loggerName", args.LoggerName),
|
|
log.Stringer("logLevel", args.LogLevel),
|
|
log.Stringer("displayLevel", args.DisplayLevel),
|
|
)
|
|
|
|
if args.LogLevel == nil && args.DisplayLevel == nil {
|
|
return errNoLogLevel
|
|
}
|
|
|
|
a.lock.Lock()
|
|
defer a.lock.Unlock()
|
|
|
|
loggerNames := a.getLoggerNames(args.LoggerName)
|
|
// LogFactory methods not available in new log module
|
|
// for _, name := range loggerNames {
|
|
// if args.LogLevel != nil {
|
|
// if err := a.LogFactory.SetLogLevel(name, *args.LogLevel); err != nil {
|
|
// return err
|
|
// }
|
|
// }
|
|
// if args.DisplayLevel != nil {
|
|
// if err := a.LogFactory.SetDisplayLevel(name, *args.DisplayLevel); err != nil {
|
|
// return err
|
|
// }
|
|
// }
|
|
// }
|
|
|
|
var err error
|
|
reply.LoggerLevels, err = a.getLogLevels(loggerNames)
|
|
return err
|
|
}
|
|
|
|
type GetLoggerLevelArgs struct {
|
|
LoggerName string `json:"loggerName"`
|
|
}
|
|
|
|
// GetLoggerLevel returns the log level and display level of all loggers.
|
|
func (a *Admin) GetLoggerLevel(_ *http.Request, args *GetLoggerLevelArgs, reply *LoggerLevelReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "getLoggerLevel"),
|
|
log.String("loggerName", args.LoggerName),
|
|
)
|
|
|
|
a.lock.RLock()
|
|
defer a.lock.RUnlock()
|
|
|
|
loggerNames := a.getLoggerNames(args.LoggerName)
|
|
|
|
var err error
|
|
reply.LoggerLevels, err = a.getLogLevels(loggerNames)
|
|
return err
|
|
}
|
|
|
|
// GetConfig returns the config that the node was started with.
|
|
func (a *Admin) GetConfig(_ *http.Request, _ *struct{}, reply *interface{}) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "getConfig"),
|
|
)
|
|
*reply = a.NodeConfig
|
|
return nil
|
|
}
|
|
|
|
// LoadVMsReply contains the response metadata for LoadVMs
|
|
type LoadVMsReply struct {
|
|
// VMs and their aliases which were successfully loaded
|
|
NewVMs map[ids.ID][]string `json:"newVMs"`
|
|
// VMs that failed to be loaded and the error message
|
|
FailedVMs map[ids.ID]string `json:"failedVMs,omitempty"`
|
|
// ChainsRetried is the number of chains that were re-queued for creation
|
|
// after VMs were hot-loaded
|
|
ChainsRetried int `json:"chainsRetried,omitempty"`
|
|
}
|
|
|
|
// LoadVMs loads any new VMs available to the node and returns the added VMs.
|
|
// After loading new VMs, it retries creating any chains that were waiting for
|
|
// those VMs (hot-loading support).
|
|
func (a *Admin) LoadVMs(r *http.Request, _ *struct{}, reply *LoadVMsReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "loadVMs"),
|
|
)
|
|
|
|
a.lock.Lock()
|
|
defer a.lock.Unlock()
|
|
|
|
ctx := r.Context()
|
|
loadedVMs, failedVMs, err := a.VMRegistry.Reload(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// extract the inner error messages
|
|
failedVMsParsed := make(map[ids.ID]string)
|
|
for vmID, err := range failedVMs {
|
|
failedVMsParsed[vmID] = err.Error()
|
|
}
|
|
|
|
reply.FailedVMs = failedVMsParsed
|
|
newVMs := make(map[ids.ID][]string, len(loadedVMs))
|
|
for _, vmID := range loadedVMs {
|
|
aliases, err := a.VMManager.Aliases(ctx, vmID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Registration aliases every VM to its own id, and that one is not news
|
|
// to a caller reading it as the key. Copied rather than resliced: the
|
|
// aliases belong to whoever handed them over.
|
|
self := vmID.String()
|
|
named := make([]string, 0, len(aliases))
|
|
for _, alias := range aliases {
|
|
if alias != self {
|
|
named = append(named, alias)
|
|
}
|
|
}
|
|
newVMs[vmID] = named
|
|
}
|
|
reply.NewVMs = newVMs
|
|
|
|
// Hot-loading: retry chains that were waiting for these VMs
|
|
totalRetried := 0
|
|
for _, vmID := range loadedVMs {
|
|
retried := a.ChainManager.RetryPendingChains(vmID)
|
|
if retried > 0 {
|
|
a.Log.Info("Retrying pending chains after VM hot-load",
|
|
log.Stringer("vmID", vmID),
|
|
log.Int("chainsRetried", retried),
|
|
)
|
|
}
|
|
totalRetried += retried
|
|
}
|
|
reply.ChainsRetried = totalRetried
|
|
|
|
return nil
|
|
}
|
|
|
|
func (a *Admin) getLoggerNames(loggerName string) []string {
|
|
if len(loggerName) == 0 {
|
|
// LogFactory.GetLoggerNames not available
|
|
return []string{}
|
|
}
|
|
return []string{loggerName}
|
|
}
|
|
|
|
func (a *Admin) getLogLevels(loggerNames []string) (map[string]LogAndDisplayLevels, error) {
|
|
loggerLevels := make(map[string]LogAndDisplayLevels)
|
|
// LogFactory methods not available
|
|
// for _, name := range loggerNames {
|
|
// logLevel, err := a.LogFactory.GetLogLevel(name)
|
|
// if err != nil {
|
|
// return nil, err
|
|
// }
|
|
// displayLevel, err := a.LogFactory.GetDisplayLevel(name)
|
|
// if err != nil {
|
|
// return nil, err
|
|
// }
|
|
// loggerLevels[name] = LogAndDisplayLevels{
|
|
// LogLevel: logLevel,
|
|
// DisplayLevel: displayLevel,
|
|
// }
|
|
// }
|
|
return loggerLevels, nil
|
|
}
|
|
|
|
type DBGetArgs struct {
|
|
Key string `json:"key"`
|
|
}
|
|
|
|
type DBGetReply struct {
|
|
Value string `json:"value"`
|
|
}
|
|
|
|
//nolint:staticcheck // renaming this method to DBGet would change the API method from "dbGet" to "dBGet"
|
|
func (a *Admin) DbGet(_ *http.Request, args *DBGetArgs, reply *DBGetReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "dbGet"),
|
|
log.String("key", args.Key),
|
|
)
|
|
|
|
key, err := formatting.Decode(formatting.HexNC, args.Key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
value, err := a.DB.Get(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
reply.Value, err = formatting.Encode(formatting.HexNC, value)
|
|
return err
|
|
}
|
|
|
|
// VMInfo contains information about a registered VM
|
|
type VMInfo struct {
|
|
ID string `json:"id"`
|
|
Aliases []string `json:"aliases"`
|
|
Path string `json:"path,omitempty"`
|
|
}
|
|
|
|
// ListVMsReply contains the response for ListVMs
|
|
type ListVMsReply struct {
|
|
VMs map[string]VMInfo `json:"vms"`
|
|
}
|
|
|
|
// ListVMs returns all registered VMs with their IDs, aliases, and paths
|
|
func (a *Admin) ListVMs(r *http.Request, _ *struct{}, reply *ListVMsReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "listVMs"),
|
|
)
|
|
|
|
a.lock.RLock()
|
|
defer a.lock.RUnlock()
|
|
|
|
// Get all registered VM IDs
|
|
vmIDs, err := a.VMManager.ListFactories(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Build a map of plugin files in the plugin directory for path lookup
|
|
pluginPaths := make(map[ids.ID]string)
|
|
if a.PluginDir != "" {
|
|
entries, err := os.ReadDir(a.PluginDir)
|
|
if err == nil {
|
|
for _, entry := range entries {
|
|
if entry.IsDir() {
|
|
continue
|
|
}
|
|
name := entry.Name()
|
|
// Strip extension for matching
|
|
baseName := name[:len(name)-len(filepath.Ext(name))]
|
|
if baseName == "" {
|
|
continue
|
|
}
|
|
// Try to parse as VM ID
|
|
vmID, err := ids.FromString(baseName)
|
|
if err == nil {
|
|
pluginPaths[vmID] = filepath.Join(a.PluginDir, name)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
reply.VMs = make(map[string]VMInfo, len(vmIDs))
|
|
for _, vmID := range vmIDs {
|
|
aliases, err := a.VMManager.Aliases(r.Context(), vmID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Filter out the vmID string from aliases (it's always included)
|
|
vmIDStr := vmID.String()
|
|
filteredAliases := make([]string, 0, len(aliases))
|
|
for _, alias := range aliases {
|
|
if alias != vmIDStr {
|
|
filteredAliases = append(filteredAliases, alias)
|
|
}
|
|
}
|
|
|
|
info := VMInfo{
|
|
ID: vmIDStr,
|
|
Aliases: filteredAliases,
|
|
}
|
|
|
|
// Add path if found in plugin directory
|
|
if pluginPath, ok := pluginPaths[vmID]; ok {
|
|
info.Path = pluginPath
|
|
}
|
|
|
|
reply.VMs[vmIDStr] = info
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SetTrackedChainsArgs are the arguments for SetTrackedChains
|
|
type SetTrackedChainsArgs struct {
|
|
Chains []string `json:"chains"`
|
|
}
|
|
|
|
// SetTrackedChainsReply is the response from SetTrackedChains
|
|
type SetTrackedChainsReply struct {
|
|
TrackedChains []string `json:"trackedChains"`
|
|
}
|
|
|
|
// SetTrackedChains adds chains to be tracked by this node at runtime.
|
|
// This enables the node to track new chains without requiring a restart.
|
|
func (a *Admin) SetTrackedChains(_ *http.Request, args *SetTrackedChainsArgs, reply *SetTrackedChainsReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "setTrackedChains"),
|
|
)
|
|
|
|
if a.Network == nil {
|
|
return errors.New("network not available")
|
|
}
|
|
|
|
a.lock.Lock()
|
|
defer a.lock.Unlock()
|
|
|
|
for _, chainStr := range args.Chains {
|
|
chainID, err := ids.FromString(chainStr)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid chain ID %q: %w", chainStr, err)
|
|
}
|
|
|
|
if err := a.Network.TrackChain(chainID); err != nil {
|
|
return fmt.Errorf("failed to track chain %s: %w", chainID, err)
|
|
}
|
|
|
|
a.Log.Info("chain now tracked",
|
|
log.Stringer("chainID", chainID),
|
|
)
|
|
}
|
|
|
|
// Return the updated list of tracked chains
|
|
trackedChains := a.Network.TrackedChains()
|
|
reply.TrackedChains = make([]string, 0, trackedChains.Len())
|
|
for chainID := range trackedChains {
|
|
reply.TrackedChains = append(reply.TrackedChains, chainID.String())
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetTrackedChainsReply is the response from GetTrackedChains
|
|
type GetTrackedChainsReply struct {
|
|
TrackedChains []string `json:"trackedChains"`
|
|
}
|
|
|
|
// GetTrackedChains returns the list of chains currently being tracked by this node.
|
|
func (a *Admin) GetTrackedChains(_ *http.Request, _ *struct{}, reply *GetTrackedChainsReply) error {
|
|
a.Log.Debug("API called",
|
|
log.String("service", "admin"),
|
|
log.String("method", "getTrackedChains"),
|
|
)
|
|
|
|
if a.Network == nil {
|
|
return errors.New("network not available")
|
|
}
|
|
|
|
a.lock.RLock()
|
|
defer a.lock.RUnlock()
|
|
|
|
trackedChains := a.Network.TrackedChains()
|
|
reply.TrackedChains = make([]string, 0, trackedChains.Len())
|
|
for chainID := range trackedChains {
|
|
reply.TrackedChains = append(reply.TrackedChains, chainID.String())
|
|
}
|
|
|
|
return nil
|
|
}
|