From 7c03a2913156a71fe4b3be6683a047128152b8c6 Mon Sep 17 00:00:00 2001 From: Nevio Date: Thu, 28 Mar 2024 21:11:50 +0100 Subject: [PATCH] Porting over simulator from #140 - broken --- simulator/anvil_methods.go | 24 ++ simulator/anvil_provider.go | 486 ++++++++++++++++++++++++++++ simulator/anvil_provider_options.go | 85 +++++ simulator/doc.go | 7 + simulator/faucet.go | 73 +++++ simulator/helpers.go | 17 + simulator/node.go | 301 +++++++++++++++++ simulator/options.go | 38 +++ simulator/provider.go | 63 ++++ simulator/simulator.go | 388 ++++++++++++++++++++++ simulator/simulator_test.go | 253 +++++++++++++++ simulator/status.go | 46 +++ simulator/test_helpers.go | 80 +++++ 13 files changed, 1861 insertions(+) create mode 100644 simulator/anvil_methods.go create mode 100644 simulator/anvil_provider.go create mode 100644 simulator/anvil_provider_options.go create mode 100644 simulator/doc.go create mode 100644 simulator/faucet.go create mode 100644 simulator/helpers.go create mode 100644 simulator/node.go create mode 100644 simulator/options.go create mode 100644 simulator/provider.go create mode 100644 simulator/simulator.go create mode 100644 simulator/simulator_test.go create mode 100644 simulator/status.go create mode 100644 simulator/test_helpers.go diff --git a/simulator/anvil_methods.go b/simulator/anvil_methods.go new file mode 100644 index 00000000..d565fa01 --- /dev/null +++ b/simulator/anvil_methods.go @@ -0,0 +1,24 @@ +package simulator + +import ( + "github.com/ethereum/go-ethereum/common" +) + +// https://book.getfoundry.sh/reference/anvil/#custom-methods +// TODO: We should integrate all anvil custom methods into the simulator package. +// WARN: Methods such as autoImpersonateAccount, reset, setRpcUrl, setLoggingEnabled have direct impact to the nodes and simulator +// and should be treated according. + +// ImpersonateAccount requests the binding manager to impersonate a specified account. +// This is typically used in a testing environment to simulate transactions and interactions +// from the perspective of the given account. +func (a *AnvilProvider) ImpersonateAccount(contract common.Address) (common.Address, error) { + return a.bindingManager.ImpersonateAccount(a.Network(), contract) +} + +// StopImpersonateAccount instructs the binding manager to stop impersonating a specified account. +// This method reverts the effects of ImpersonateAccount, ceasing any further simulation of transactions +// or interactions from the perspective of the given account. +func (a *AnvilProvider) StopImpersonateAccount(contract common.Address) (common.Address, error) { + return a.bindingManager.StopImpersonateAccount(a.Network(), contract) +} diff --git a/simulator/anvil_provider.go b/simulator/anvil_provider.go new file mode 100644 index 00000000..6ae480db --- /dev/null +++ b/simulator/anvil_provider.go @@ -0,0 +1,486 @@ +package simulator + +import ( + "context" + "encoding/json" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "sync" + "syscall" + "time" + + "github.com/google/uuid" + "github.com/unpackdev/solgo/accounts" + "github.com/unpackdev/solgo/bindings" + "github.com/unpackdev/solgo/clients" + "github.com/unpackdev/solgo/utils" + "go.uber.org/zap" +) + +// AnvilProvider is a component of the simulator that manages blockchain simulation nodes. +// It holds a reference to the simulation context, various options for the provider, and +// manages a collection of active nodes and a client pool. +type AnvilProvider struct { + ctx context.Context // The context for managing the lifecycle of the provider. + opts *AnvilProviderOptions // Configuration options for the Anvil provider. + nodes map[uint64]*Node // Collection of active simulation nodes. + mu sync.Mutex // Mutex for managing concurrent access to the provider. + pool *clients.ClientPool // Client pool for managing simulated clients. + simulator *Simulator // Reference to the parent Simulator. + bindingManager *bindings.Manager // Binding manager for managing contract bindings. +} + +// NewAnvilProvider initializes a new instance of AnvilProvider with the given context, +// simulator reference, and options. It validates the provided options and sets up +// the initial state for the provider. +func NewAnvilProvider(ctx context.Context, simulator *Simulator, opts *AnvilProviderOptions) (Provider, error) { + if opts == nil { + return nil, fmt.Errorf("in order to create a new anvil provider, you must provide options") + } + + if simulator == nil { + return nil, fmt.Errorf("in order to create a new anvil provider, you must provide a simulator") + } + + if err := opts.Validate(); err != nil { + return nil, fmt.Errorf("failed to validate anvil provider options: %s", err) + } + + provider := &AnvilProvider{ + ctx: ctx, + opts: opts, + pool: simulator.GetClientPool(), + simulator: simulator, + nodes: make(map[uint64]*Node), + mu: sync.Mutex{}, + } + + return provider, nil +} + +// GetBindingManager returns the binding manager associated with the AnvilProvider. +func (a *AnvilProvider) GetBindingManager() *bindings.Manager { + return a.bindingManager +} + +// SetBindingManager sets the binding manager associated with the AnvilProvider. +func (a *AnvilProvider) SetBindingManager(bindingManager *bindings.Manager) { + a.bindingManager = bindingManager +} + +// Name returns a human-readable name for the AnvilProvider. +func (a *AnvilProvider) Name() string { + return "Foundry Anvil Node Simulator" +} + +// Network returns the network type associated with the AnvilProvider. +func (a *AnvilProvider) Network() utils.Network { + return utils.AnvilNetwork +} + +// Type returns the simulator type for the AnvilProvider. +func (a *AnvilProvider) Type() utils.SimulatorType { + return utils.AnvilSimulator +} + +// NetworkID returns the network ID associated with the AnvilProvider. +func (a *AnvilProvider) NetworkID() utils.NetworkID { + return a.opts.NetworkID +} + +// GetCmdArguments builds the command-line arguments for starting the node... +// @TODO: Fetch arguments based on provider, not just for Anvil. +func (a *AnvilProvider) GetCmdArguments(node *Node) []string { + args := []string{ + "--auto-impersonate", + "--accounts", "0", + "--host", node.Addr.IP.String(), + "--port", fmt.Sprintf("%d", node.Addr.Port), + } + + ipcPath := filepath.Join(node.IpcPath, fmt.Sprintf("anvil.%d.ipc", node.Addr.Port)) + args = append(args, "--ipc", ipcPath) + + if node.Fork { + args = append(args, "--fork-url", node.ForkEndpoint) + args = append(args, "--chain-id", fmt.Sprintf("%d", node.GetProvider().NetworkID())) + } + + if node.BlockNumber != nil { + args = append(args, "--fork-block-number", node.BlockNumber.String()) + } + + return args +} + +// Load initializes and loads the Anvil simulation nodes. It ensures that existing nodes +// are properly managed and new nodes are created as needed. It is crucial for avoiding +// zombie processes and ensuring a clean simulation environment. +func (a *AnvilProvider) Load(ctx context.Context) error { + + // Lets go through process of shutting down any existing zombie nodes... + if err := a.ResolveZombieNodes(ctx); err != nil { + return fmt.Errorf("failed to resolve zombie nodes: %w", err) + } + + // Now we are going to load remaining of the nodes that are not running yet. + if remainingClientsCount := a.NeedClients(); remainingClientsCount > 0 { + for i := 0; i < remainingClientsCount; i++ { + port := a.GetNextPort() + if port == 0 { + return fmt.Errorf("no available ports to start anvil nodes") + } + + startOpts := StartOptions{ + Fork: a.opts.Fork, + ForkEndpoint: a.opts.ForkEndpoint, + Addr: net.TCPAddr{ + IP: a.opts.IPAddr, + Port: port, + }, + } + + node, err := a.Start(ctx, startOpts) + if err != nil { + return fmt.Errorf("failed to spawn anvil node: %w", err) + } + + // Lets now load faucet accounts for the newly spawned node + if a.simulator.opts.FaucetsEnabled { + if a.simulator.opts.FaucetsAutoReplenishEnabled { + if err := a.SetupFaucetAccounts(ctx, node); err != nil { + return fmt.Errorf("failed to load faucet accounts: %w", err) + } + } + } + } + } + + return nil +} + +// Unload stops and cleans up all Anvil simulation nodes managed by the provider. +func (a *AnvilProvider) Unload(ctx context.Context) error { + for _, node := range a.nodes { + if err := node.Stop(ctx, false); err != nil { + return fmt.Errorf("failed to stop anvil node: %s", err) + } + } + + return nil +} + +// Start initializes and starts a new simulation node with the given options. +func (a *AnvilProvider) Start(ctx context.Context, opts StartOptions) (*Node, error) { + if node, ok := a.nodes[uint64(opts.Addr.Port)]; ok { + return node, nil + } + + node := &Node{ + provider: a, + simulator: a.simulator, + ID: uuid.New(), + Addr: opts.Addr, + IpcPath: a.opts.PidPath, + PidPath: a.opts.PidPath, + AutoImpersonate: a.opts.AutoImpersonate, + ExecutablePath: a.opts.AnvilExecutablePath, + Fork: a.opts.Fork, + ForkEndpoint: a.opts.ForkEndpoint, + BlockNumber: opts.BlockNumber, + } + + // Ability to override the fork defaults if needed + if opts.Fork { + node.Fork = opts.Fork + node.ForkEndpoint = opts.ForkEndpoint + } + + // Ability to override the auto impersonate defaults if needed + if opts.AutoImpersonate { + node.AutoImpersonate = opts.AutoImpersonate + } + + if err := node.Start(ctx); err != nil { + return nil, fmt.Errorf("failed to start anvil node: %w", err) + } + + zap.L().Info( + "Anvil node successfully started", + zap.String("id", node.GetID().String()), + zap.String("addr", node.Addr.String()), + zap.String("network", node.GetProvider().Network().String()), + zap.Any("network_id", node.GetProvider().NetworkID()), + zap.Uint64("block_number", node.BlockNumber.Uint64()), + ) + + // Lets register the node with the client pool + err := a.pool.RegisterClient( + ctx, + uint64(a.NetworkID()), + utils.AnvilSimulator.String(), + node.GetID().String(), + node.GetNodeAddr(), + 1, // We are going to have only one concurrent client per node + ) + + if err != nil { + return nil, fmt.Errorf( + "failed to register client with client pool: %s", + err.Error(), + ) + } + + a.mu.Lock() + if _, ok := a.nodes[opts.BlockNumber.Uint64()]; !ok { + a.nodes[opts.BlockNumber.Uint64()] = node + } + a.mu.Unlock() + + return node, nil +} + +// Stop terminates a simulation node based on the provided StopOptions. +func (a *AnvilProvider) Stop(ctx context.Context, opts StopOptions) error { + if node, found := a.GetNodeByPort(opts.Port); found { + if err := node.Stop(ctx, opts.Force); err != nil { + return fmt.Errorf("failed to stop anvil node: %s", err) + } + } + return nil +} + +// Status retrieves the status of all simulation nodes managed by the provider. +func (a *AnvilProvider) Status(ctx context.Context) ([]*NodeStatus, error) { + var statuses []*NodeStatus + + zap.L().Debug( + "Checking up on Anvil nodes status...", + zap.String("network", a.Network().String()), + zap.Any("network_id", a.NetworkID()), + ) + + for _, node := range a.nodes { + if status, err := node.Status(ctx); err != nil { + return nil, err + } else { + statuses = append(statuses, status) + } + } + + return statuses, nil +} + +// SetupFaucetAccounts prepares faucet accounts for a given simulation node in the AnvilProvider. +// +// This function is responsible for initializing and setting up faucet accounts that are essential for simulating +// blockchain transactions. It is typically used in testing environments where simulated accounts with pre-filled +// balances are required. +func (a *AnvilProvider) SetupFaucetAccounts(ctx context.Context, node *Node) error { + zap.L().Info( + "Loading faucet accounts...", + zap.String("id", node.GetID().String()), + zap.String("addr", node.Addr.String()), + zap.String("network", node.GetProvider().Network().String()), + zap.Any("network_id", node.GetProvider().NetworkID()), + zap.Uint64("block_number", node.BlockNumber.Uint64()), + ) + + wg := sync.WaitGroup{} + + for _, address := range a.simulator.faucets.List(a.Network()) { + wg.Add(1) + + client := a.pool.GetClient(utils.AnvilSimulator.String(), node.GetID().String()) + address.SetClient(client) + + go func(address *accounts.Account) { + + defer wg.Done() + if err := address.SetAccountBalance(ctx, a.simulator.opts.FaucetAccountDefaultBalance); err != nil { + zap.L().Error( + fmt.Sprintf("failure to set account balance: %s", err.Error()), + zap.String("account", address.GetAddress().String()), + zap.String("id", node.GetID().String()), + zap.String("addr", node.Addr.String()), + zap.String("network", node.GetProvider().Network().String()), + zap.Any("network_id", node.GetProvider().NetworkID()), + zap.Uint64("block_number", node.BlockNumber.Uint64()), + ) + } + + for i := 0; i < 2; i++ { + balance, err := address.Balance(ctx, nil) + if err != nil { + zap.L().Error( + fmt.Sprintf("failure to get account balance: %s", err.Error()), + zap.String("account", address.GetAddress().String()), + zap.String("id", node.GetID().String()), + zap.String("addr", node.Addr.String()), + zap.String("network", node.GetProvider().Network().String()), + zap.Any("network_id", node.GetProvider().NetworkID()), + zap.Uint64("block_number", node.BlockNumber.Uint64()), + ) + time.Sleep(500 * time.Millisecond) + continue + } + + if balance.Cmp(a.simulator.opts.FaucetAccountDefaultBalance) != 0 { + zap.L().Debug( + "Account balance successfully set", + zap.String("account", address.GetAddress().String()), + zap.String("id", node.GetID().String()), + zap.String("addr", node.Addr.String()), + zap.String("network", node.GetProvider().Network().String()), + zap.Any("network_id", node.GetProvider().NetworkID()), + zap.Uint64("block_number", node.BlockNumber.Uint64()), + zap.String("balance", balance.String()), + ) + time.Sleep(500 * time.Millisecond) + continue + } + + break + } + }(address) + } + + wg.Wait() + + return nil +} + +// GetNodes returns a map of all currently active simulation nodes managed by the AnvilProvider. +func (a *AnvilProvider) GetNodes() map[uint64]*Node { + return a.nodes +} + +// GetNodeByBlockNumber retrieves a simulation node corresponding to a specific block number. +// Returns the node and a boolean indicating whether such a node was found. +func (a *AnvilProvider) GetNodeByBlockNumber(blockNumber *big.Int) (*Node, bool) { + if blockNumber == nil { + return nil, false + } + + node, ok := a.nodes[blockNumber.Uint64()] + return node, ok +} + +// GetNodeByPort searches for a simulation node by its port number. +// Returns the node and a boolean indicating whether a node with the given port was found. +func (a *AnvilProvider) GetNodeByPort(port int) (*Node, bool) { + for _, node := range a.nodes { + if node.Addr.Port == port { + return node, true + } + } + return nil, false +} + +// GetClientByGroupAndType retrieves a client from the client pool based on the given simulator type and group. +// Returns the client and a boolean indicating whether the client was found. +func (a *AnvilProvider) GetClientByGroupAndType(simulatorType utils.SimulatorType, group string) (*clients.Client, bool) { + if client := a.pool.GetClientByGroupAndType(simulatorType.String(), group); client != nil { + return client, true + } + + return nil, false +} + +// GetClientPool returns the client pool associated with the AnvilProvider. +func (a *AnvilProvider) GetClientPool() *clients.ClientPool { + return a.pool +} + +// NeedClients calculates the number of additional clients needed to reach the desired client count. +// Returns the number of additional clients required. +func (a *AnvilProvider) NeedClients() int { + return int(a.opts.ClientCount) - len(a.nodes) +} + +// PortAvailable checks if a specific port is available both in the simulation nodes +// and on the OS level. Returns true if the port is available, false otherwise. +func (a *AnvilProvider) PortAvailable(port int) bool { + // First, check if the port is in use by any simulation node. + if _, ok := a.GetNodeByPort(port); ok { + return false + } + + // Now, check if the port is available on the OS. + address := fmt.Sprintf(":%d", port) + listener, err := net.Listen("tcp", address) + if err != nil { + // If there is an error opening the listener, the port is not available. + return false + } + + // Don't forget to close the listener if the port is available. + listener.Close() + + return true +} + +// GetNextPort identifies the next available port within the specified port range in the provider options. +// Returns the next available port number or 0 if no ports are available. +func (a *AnvilProvider) GetNextPort() int { + for i := a.opts.StartPort; i <= a.opts.EndPort; i++ { + if a.PortAvailable(i) { + return i + } + } + return 0 +} + +func (a *AnvilProvider) ResolveZombieNodes(ctx context.Context) error { + pidPath := a.opts.PidPath + files, err := os.ReadDir(pidPath) + if err != nil { + return fmt.Errorf("failed to read simulator processes directory: %w", err) + } + + for _, file := range files { + if filepath.Ext(file.Name()) != ".json" { + continue + } + + filePath := filepath.Join(pidPath, file.Name()) + fileBytes, err := os.ReadFile(filePath) + if err != nil { + zap.L().Error("Failed to read zombie simulator node file", zap.String("path", filePath), zap.Error(err)) + continue + } + + var node *Node + + if err := json.Unmarshal(fileBytes, &node); err != nil { + zap.L().Error("Failed to unmarshal zombie simulator node file", zap.String("path", filePath), zap.Error(err)) + continue + } + + if process, err := os.FindProcess(node.PID); err == nil { + if err := process.Signal(syscall.SIGTERM); err == nil { + zap.L().Info( + "Successfully terminated zombie simulator node", + zap.String("path", filePath), + zap.Any("pid", node.PID), + zap.Any("network", utils.AnvilNetwork), + ) + } + } + + pidFileName := fmt.Sprintf("anvil.%d.pid.json", node.Addr.Port) + if err := os.Remove(filepath.Join(node.PidPath, pidFileName)); err != nil { + zap.L().Error("Failed to remove zombie simulator node file", zap.String("path", filePath), zap.Error(err)) + } + + pidFileName = fmt.Sprintf("anvil.%d.ipc", node.Addr.Port) + if err := os.Remove(filepath.Join(node.PidPath, pidFileName)); err != nil { + zap.L().Error("Failed to remove zombie simulator node file", zap.String("path", filePath), zap.Error(err)) + } + } + + return nil +} diff --git a/simulator/anvil_provider_options.go b/simulator/anvil_provider_options.go new file mode 100644 index 00000000..44ceb497 --- /dev/null +++ b/simulator/anvil_provider_options.go @@ -0,0 +1,85 @@ +package simulator + +import ( + "fmt" + "net" + + "github.com/unpackdev/solgo/utils" +) + +const ( + // MaxAnvilSimulatedClients defines the maximum number of clients that can be simulated. + MaxAnvilSimulatedClients = 100 +) + +// AnvilProviderOptions defines the configuration options for an Anvil simulator provider. +// These options specify how the Anvil nodes should be set up and run, including network +// settings, client counts, executable paths, and forking options. +type AnvilProviderOptions struct { + Network utils.Network `json:"network"` + NetworkID utils.NetworkID `json:"network_id"` + ClientCount int32 `json:"client_count"` + MaxClientCount int32 `json:"max_client_count"` + IPAddr net.IP `json:"ip_addr"` + StartPort int `json:"start_port"` + EndPort int `json:"end_port"` + PidPath string `json:"pid_path"` + AnvilExecutablePath string `json:"anvil_binary_path"` + Fork bool `json:"fork"` + ForkEndpoint string `json:"fork_endpoint"` + AutoImpersonate bool `json:"auto_impersonate"` +} + +// Validate checks the validity of the AnvilProviderOptions. It ensures that all necessary +// configurations are set correctly and within acceptable ranges. This includes checking +// client counts, path existence, network settings, and port configurations. +// Returns an error if any validation check fails. +func (a *AnvilProviderOptions) Validate() error { + if a.MaxClientCount > MaxAnvilSimulatedClients { + return fmt.Errorf("max simulated clients must be less then %d", MaxAnvilSimulatedClients) + } + + if a.ClientCount > a.MaxClientCount { + return fmt.Errorf("simulated clients must be less than or equal to max simulated clients") + } + + if a.PidPath == "" { + return fmt.Errorf("pid path must be provided") + } else { + if !utils.PathExists(a.PidPath) { + return fmt.Errorf("pid path does not exist: %s", a.PidPath) + } + } + + if a.AnvilExecutablePath == "" { + return fmt.Errorf("anvil executable path must be provided") + } else { + if !utils.PathExists(a.AnvilExecutablePath) { + return fmt.Errorf("anvil executable path does not exist: %s", a.AnvilExecutablePath) + } + } + + if a.Fork { + if a.ClientCount > 1 { + return fmt.Errorf("initial forking is only supported with a single client. Remaining will be spawned as needed") + } + + if a.ForkEndpoint == "" { + return fmt.Errorf("fork endpoint must be provided") + } + } + + if a.IPAddr.String() == "" { + return fmt.Errorf("ip address must be provided") + } + + if a.StartPort < 2000 || a.StartPort > 65535 { + return fmt.Errorf("start port must be between 2000 and 65535") + } + + if a.EndPort < a.StartPort+1 || a.EndPort > 65535 { + return fmt.Errorf("end port must be between %d and 65535", a.StartPort+1) + } + + return nil +} diff --git a/simulator/doc.go b/simulator/doc.go new file mode 100644 index 00000000..695d826c --- /dev/null +++ b/simulator/doc.go @@ -0,0 +1,7 @@ +// Package simulator provides a suite of tools and components for simulating +// blockchain environments, specifically tailored for Anvil, a blockchain simulation tool. +// It includes functionalities for creating, managing, and interacting with simulated +// blockchain nodes and environments. +// It includes components like Simulator, Provider, and Faucet to manage and simulate +// blockchain operations, primarily for testing and development purposes. +package simulator diff --git a/simulator/faucet.go b/simulator/faucet.go new file mode 100644 index 00000000..38ed5dda --- /dev/null +++ b/simulator/faucet.go @@ -0,0 +1,73 @@ +package simulator + +import ( + "context" + "fmt" + "time" + + "github.com/unpackdev/solgo/accounts" + "github.com/unpackdev/solgo/clients" + "github.com/unpackdev/solgo/utils" +) + +// Faucet is responsible for creating and managing simulated blockchain accounts. +// It integrates with solgo's accounts.Manager to leverage existing account management features. +// Faucet is primarily used in testing environments where multiple accounts with +// specific configurations are required. +type Faucet struct { + *accounts.Manager + ctx context.Context + opts *Options + clientPool *clients.ClientPool +} + +// NewFaucet creates a new instance of Faucet. It requires a context and options to +// initialize the underlying accounts manager and other configurations. +// Returns an error if the options are not provided or if the accounts manager fails to initialize. +func NewFaucet(ctx context.Context, clientPool *clients.ClientPool, opts *Options) (*Faucet, error) { + if opts == nil { + return nil, fmt.Errorf("in order to create a new faucet, you must provide options") + } + + manager, err := accounts.NewManager(ctx, clientPool, &accounts.Options{KeystorePath: opts.KeystorePath, SupportedNetworks: opts.SupportedNetworks}) + if err != nil { + return nil, fmt.Errorf("failed to create new faucet manager: %w", err) + } + + return &Faucet{ + ctx: ctx, + opts: opts, + Manager: manager, + clientPool: clientPool, + }, nil +} + +// Create generates a new simulated account for a specific network. +// This method is particularly useful in testing scenarios where multiple accounts +// are needed with different configurations. If no password is provided, the default +// password from Faucet options is used. The account can be optionally pinned, and +// additional tags can be assigned for further categorization or identification. +// Returns the created account or an error if the account creation fails. +func (f *Faucet) Create(network utils.Network, password string, pin bool, tags ...string) (*accounts.Account, error) { + tags = append(tags, utils.SimulatorAccountType.String()) + + var pwd string + if password == "" { + pwd = f.opts.DefaultPassword + } else { + pwd = password + } + + var account *accounts.Account + var err error + attempts := 3 + for i := 0; i < attempts; i++ { + account, err = f.Manager.Create(network, pwd, pin, tags...) + if err == nil { + time.Sleep(100 * time.Millisecond) + return account, nil + } + } + + return nil, fmt.Errorf("failed to generate faucet account for network: %s after %d attempts, err: %s", network, attempts, err) +} diff --git a/simulator/helpers.go b/simulator/helpers.go new file mode 100644 index 00000000..fc318b09 --- /dev/null +++ b/simulator/helpers.go @@ -0,0 +1,17 @@ +package simulator + +// ToAnvilProvider attempts to cast a generic Provider interface to a specific *AnvilProvider type. +// This is useful when you need to work with the specific methods and properties of AnvilProvider +// that are not part of the generic Provider interface. +func ToAnvilProvider(provider Provider) (*AnvilProvider, bool) { + if provider == nil { + return nil, false + } + + anvilProvider, ok := provider.(*AnvilProvider) + if !ok { + return nil, false + } + + return anvilProvider, true +} diff --git a/simulator/node.go b/simulator/node.go new file mode 100644 index 00000000..9b7a1b7e --- /dev/null +++ b/simulator/node.go @@ -0,0 +1,301 @@ +package simulator + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math/big" + "net" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + + "github.com/google/uuid" + "go.uber.org/zap" +) + +// Node represents a single node in the simulation environment. It encapsulates the +// details and operations for a blockchain simulation node. +type Node struct { + cmd *exec.Cmd `json:"-"` // The command used to start the node process. Not exported in JSON. + simulator *Simulator `json:"-"` // Reference to the Simulator instance managing this node. Not exported in JSON. + provider Provider `json:"-"` // The Provider instance representing the blockchain network provider. Not exported in JSON. + ID uuid.UUID `json:"id"` // Unique identifier for the node. + PID int `json:"pid"` // Process ID of the running node. + Addr net.TCPAddr `json:"addr"` // TCP address on which the node is running. + IpcPath string `json:"ipc_path"` // The file path for the IPC endpoint of the node. + AutoImpersonate bool `json:"auto_impersonate"` // Flag indicating whether the node should automatically impersonate accounts. + BlockNumber *big.Int `json:"block_number"` // The block number from which the node is operating, if applicable. + PidPath string `json:"pid_path"` // The file path where the node's PID file is stored. + ExecutablePath string `json:"executable_path"` // The file path to the executable used by this node. + Fork bool `json:"fork"` // Flag indicating whether the node is running in fork mode. + ForkEndpoint string `json:"fork_endpoint"` // The endpoint URL of the blockchain to fork from, if fork mode is enabled. +} + +// GetNodeAddr returns the HTTP address of the node. +func (n *Node) GetNodeAddr() string { + return fmt.Sprintf("http://%s:%d", n.Addr.IP.String(), n.Addr.Port) +} + +// GetSimulator returns the Simulator instance associated with the node. +func (n *Node) GetSimulator() *Simulator { + return n.simulator +} + +// GetProvider returns the Provider instance associated with the node. +func (n *Node) GetProvider() Provider { + return n.provider +} + +// GetID returns the unique identifier of the node. +func (n *Node) GetID() uuid.UUID { + return n.ID +} + +// Start initiates the Anvil node's process, capturing its output and handling its lifecycle. +func (n *Node) Start(ctx context.Context) error { + started := make(chan struct{}) + + cmd := exec.CommandContext(ctx, n.ExecutablePath, n.provider.GetCmdArguments(n)...) + + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("failed to get stdout pipe: %v", err) + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return fmt.Errorf("failed to get stderr pipe: %v", err) + } + + err = cmd.Start() + if err != nil { + return fmt.Errorf("failed to start Anvil node: %v", err) + } + + n.PID = cmd.Process.Pid + n.cmd = cmd + + nodeJSON, err := json.Marshal(n) + if err != nil { + return fmt.Errorf("failed to marshal Node data to JSON: %v", err) + } + + pidFileName := fmt.Sprintf("anvil.%d.pid.json", n.Addr.Port) + filePath := filepath.Join(n.PidPath, pidFileName) + + err = os.WriteFile(filePath, nodeJSON, 0644) // Using 0644 as a common permission setting + if err != nil { + return fmt.Errorf("failed to write Node JSON to file: %v", err) + } + + go n.streamOutput(stdoutPipe, "stdout", started) + go n.streamOutput(stderrPipe, "stderr", nil) + + go func() { + err := cmd.Wait() + if err != nil { + // Ignore the error if the process was killed + if strings.Contains(err.Error(), "no child processes") || + strings.Contains(err.Error(), "signal: killed") { + return + } + + zap.L().Error("Anvil node exited with error", zap.Error(err)) + } else { + zap.L().Info("Anvil node exited successfully") + } + }() + + select { + case <-started: + return nil + case <-ctx.Done(): + return fmt.Errorf("failed to start Anvil node: %v", ctx.Err()) + } +} + +// Stop terminates the Anvil node's process, cleans up resources, and removes relevant files. +func (n *Node) Stop(ctx context.Context, force bool) error { + zap.L().Info( + "Stopping Anvil node...", + zap.String("addr", n.Addr.String()), + zap.Int("port", n.Addr.Port), + zap.String("network", n.provider.Network().String()), + zap.Any("network_id", n.provider.NetworkID()), + zap.Any("block_number", n.BlockNumber), + ) + + if n.cmd == nil || n.cmd.Process == nil { + return fmt.Errorf("process not started or already stopped") + } + + err := n.cmd.Process.Signal(os.Interrupt) // or syscall.SIGTERM, depending on how your node handles signals + if err != nil { + if !errors.Is(err, os.ErrProcessDone) { + return fmt.Errorf("failed to send interrupt signal to process: %v", err) + } + } + + if !force && err == nil { + _, err = n.cmd.Process.Wait() + if err != nil && !errors.Is(err, os.ErrProcessDone) { + return fmt.Errorf("error waiting for process to exit: %v", err) + } + } + + pidFileName := fmt.Sprintf("anvil.%d.pid.json", n.Addr.Port) + filePath := filepath.Join(n.PidPath, pidFileName) + os.Remove(filePath) + + pidFileName = fmt.Sprintf("anvil.%d.ipc", n.Addr.Port) + filePath = filepath.Join(n.PidPath, pidFileName) + os.Remove(filePath) + + zap.L().Info( + "Anvil node successfully stopped", + zap.String("addr", n.Addr.String()), + zap.Int("port", n.Addr.Port), + zap.String("network", n.provider.Network().String()), + zap.Any("network_id", n.provider.NetworkID()), + zap.Any("block_number", n.BlockNumber), + ) + + return nil +} + +// Status checks the current status of the node, including its running state and error conditions. +func (n *Node) Status(ctx context.Context) (*NodeStatus, error) { + if n.cmd == nil || n.cmd.Process == nil { + return &NodeStatus{ + ID: n.ID, + BlockNumber: n.BlockNumber.Uint64(), + IPAddr: n.Addr.IP.String(), + Port: n.Addr.Port, + Success: false, + Status: NodeStatusTypeStopped, + Error: nil, + }, nil + } + + // Check if the process is still running + process, err := os.FindProcess(n.cmd.Process.Pid) + if err != nil { + return &NodeStatus{ + ID: n.ID, + BlockNumber: n.BlockNumber.Uint64(), + IPAddr: n.Addr.IP.String(), + Port: n.Addr.Port, + Success: false, + Status: NodeStatusTypeError, + Error: fmt.Errorf("error finding process: %v", err), + }, fmt.Errorf("error finding process: %v", err) + } + + // Sending signal 0 to a process checks for its existence without killing it + err = process.Signal(syscall.Signal(0)) + if err == nil { + return &NodeStatus{ + ID: n.ID, + BlockNumber: n.BlockNumber.Uint64(), + IPAddr: n.Addr.IP.String(), + Port: n.Addr.Port, + Success: true, + Status: NodeStatusTypeRunning, + Error: nil, + }, nil + } + + if errors.Is(err, os.ErrProcessDone) { + return &NodeStatus{ + ID: n.ID, + BlockNumber: n.BlockNumber.Uint64(), + IPAddr: n.Addr.IP.String(), + Port: n.Addr.Port, + Success: true, + Status: NodeStatusTypeStopped, + Error: nil, + }, nil + } + + return &NodeStatus{ + ID: n.ID, + BlockNumber: n.BlockNumber.Uint64(), + IPAddr: n.Addr.IP.String(), + Port: n.Addr.Port, + Success: false, + Status: NodeStatusTypeError, + Error: fmt.Errorf("error checking process status: %v", err), + }, fmt.Errorf("error checking process status: %v", err) +} + +// streamOutput handles the output from the node's stdout and stderr, extracting information +// like block number and node readiness, and logging the output. +func (n *Node) streamOutput(pipe io.ReadCloser, outputType string, done chan struct{}) { + blockNumberRegex := regexp.MustCompile(`Block number:\s+(\d+)`) + listeningRegex := regexp.MustCompile(`Listening on ([\d\.]+:\d+)`) + revertedRegex := regexp.MustCompile(`Error: reverted with:\s+(.+)`) + scanner := bufio.NewScanner(pipe) + + for scanner.Scan() { + line := scanner.Text() + + if lineTrimmed := strings.TrimSpace(line); lineTrimmed != "" { + zap.L().Debug( + line, + zap.String("addr", n.Addr.String()), + zap.Int("port", n.Addr.Port), + zap.String("network", n.provider.Network().String()), + zap.Any("network_id", n.provider.NetworkID()), + zap.Any("block_number", n.BlockNumber), + ) + } + + // Check for block number in the output + if matches := blockNumberRegex.FindStringSubmatch(line); len(matches) > 1 { + blockNumber, err := strconv.ParseInt(matches[1], 10, 64) + if err == nil { + n.BlockNumber = big.NewInt(blockNumber) // Update the BlockNumber field + zap.L().Info( + "Discovered block number for Anvil node", + zap.String("addr", n.Addr.String()), + zap.Int("port", n.Addr.Port), + zap.String("network", n.provider.Network().String()), + zap.Any("network_id", n.provider.NetworkID()), + zap.Uint64("block_number", n.BlockNumber.Uint64()), + ) + } + } + + // Check for revert messages.... + if matches := revertedRegex.FindStringSubmatch(line); len(matches) > 1 { + zap.L().Error( + "Discovered revert message", + zap.Error(fmt.Errorf("%s", matches[1])), + zap.String("addr", n.Addr.String()), + zap.Int("port", n.Addr.Port), + zap.String("network", n.provider.Network().String()), + zap.Any("network_id", n.provider.NetworkID()), + zap.Uint64("block_number", n.BlockNumber.Uint64()), + ) + } + + // Check if the node is listening and if the done channel is set + if done != nil && listeningRegex.MatchString(line) { + close(done) // Close the done channel to signal readiness + done = nil // Prevent further attempts to close the channel + } + } + + if err := scanner.Err(); err != nil { + if !strings.Contains(err.Error(), "file already closed") { + zap.L().Error(fmt.Sprintf("Error reading from node %s: %v", outputType, err)) + } + } +} diff --git a/simulator/options.go b/simulator/options.go new file mode 100644 index 00000000..b4fa14c4 --- /dev/null +++ b/simulator/options.go @@ -0,0 +1,38 @@ +package simulator + +import ( + "math/big" + "net" + + "github.com/unpackdev/solgo/utils" +) + +// StartOptions defines the configuration options for starting a simulation node. +// It includes settings for forking, networking, block number, and account impersonation. +type StartOptions struct { + Fork bool `json:"fork"` // Indicates whether to fork from an existing blockchain. + ForkEndpoint string `json:"endpoint"` // Endpoint URL for forking the blockchain. + Addr net.TCPAddr `json:"port"` // TCP address for the node to listen on. + BlockNumber *big.Int `json:"block_number"` // Specific block number to start the simulation from. + AutoImpersonate bool `json:"auto_impersonate"` // Enables automatic impersonation of accounts. +} + +// StopOptions defines the configuration options for stopping a simulation node. +// It includes a forceful stop option and specifies the node to stop by its port. +type StopOptions struct { + Force bool `json:"force"` // Forcefully stops the node, potentially without cleanup. + Port int `json:"port"` // Specifies the port of the node to be stopped. +} + +// Options encapsulates the general configuration settings for the simulator. +// It includes settings for the keystore, supported networks, faucet options, and default password. +type Options struct { + Endpoint string `json:"endpoint"` // Endpoint URL for interacting with the blockchain. + KeystorePath string `json:"keystore_path"` // Filesystem path to the keystore directory. + SupportedNetworks []utils.Network `json:"supported_networks"` // List of supported blockchain networks. + FaucetsEnabled bool `json:"faucets_enabled"` // Flag to enable or disable faucet functionality. + FaucetsAutoReplenishEnabled bool `json:"faucets_auto_replenish_enabled"` // Flag to enable or disable automatic replenishment of faucet accounts. + FaucetAccountCount int `json:"auto_faucet_count"` // Number of faucet accounts to create. + FaucetAccountDefaultBalance *big.Int `json:"auto_faucet_balance"` // Default balance for each faucet account. + DefaultPassword string `json:"default_password"` // Default password for accounts managed by the simulator. +} diff --git a/simulator/provider.go b/simulator/provider.go new file mode 100644 index 00000000..7f2ef2d7 --- /dev/null +++ b/simulator/provider.go @@ -0,0 +1,63 @@ +package simulator + +import ( + "context" + "math/big" + + "github.com/unpackdev/solgo/clients" + "github.com/unpackdev/solgo/utils" +) + +// Provider defines an interface for simulation providers in the simulator package. +// It includes a set of methods for managing and interacting with simulated blockchain nodes, +// clients, and related resources. Each method in the interface serves a specific purpose in +// the lifecycle and operation of a simulation environment. +type Provider interface { + // Name returns the name of the simulation provider. + Name() string + + // Network returns the blockchain network associated with the provider. + Network() utils.Network + + // NetworkID returns the network ID associated with the provider. + NetworkID() utils.NetworkID + + // Type returns the type of the simulation provider. + Type() utils.SimulatorType + + // GetCmdArguments returns the command line arguments for the simulation provider. + GetCmdArguments(node *Node) []string + + // Load initializes the provider and loads necessary resources. + Load(context.Context) error + + // Unload releases resources and performs cleanup for the provider. + Unload(context.Context) error + + // SetupFaucetAccounts sets up faucet accounts for a given simulation node. + SetupFaucetAccounts(context.Context, *Node) error + + // Start initiates a new simulation node with the specified options. + Start(ctx context.Context, opts StartOptions) (*Node, error) + + // Stop terminates a simulation node based on provided options. + Stop(context.Context, StopOptions) error + + // Status retrieves the status of all simulation nodes managed by the provider. + Status(context.Context) ([]*NodeStatus, error) + + // GetNodes returns a map of all currently active simulation nodes. + GetNodes() map[uint64]*Node + + // GetNodeByBlockNumber retrieves a simulation node by its block number. + GetNodeByBlockNumber(*big.Int) (*Node, bool) + + // GetNodeByPort finds a simulation node based on its port number. + GetNodeByPort(int) (*Node, bool) + + // GetClientByGroupAndType retrieves a client based on the simulation type and group identifier. + GetClientByGroupAndType(utils.SimulatorType, string) (*clients.Client, bool) + + // GetClientPool returns the client pool associated with the provider. + GetClientPool() *clients.ClientPool +} diff --git a/simulator/simulator.go b/simulator/simulator.go new file mode 100644 index 00000000..73415630 --- /dev/null +++ b/simulator/simulator.go @@ -0,0 +1,388 @@ +package simulator + +import ( + "context" + "fmt" + "math/big" + "net" + "sync" + "sync/atomic" + + "github.com/unpackdev/solgo/bindings" + "github.com/unpackdev/solgo/clients" + "github.com/unpackdev/solgo/utils" + "go.uber.org/zap" +) + +// Simulator is the core struct for the simulator package. It manages the lifecycle +// and operations of blockchain simulations, including the management of providers +// and faucets. The Simulator allows for flexible interaction with various simulated +// blockchain environments and accounts. +type Simulator struct { + ctx context.Context // Context for managing lifecycle and control flow. + opts *Options // Configuration options for the Simulator. + clientPool *clients.ClientPool // Ethereum client pool + providers map[utils.SimulatorType]Provider // Registered providers for different simulation types. + mu sync.Mutex // Mutex to protect concurrent access to the providers map and other shared resources. + faucets *Faucet // Faucet for managing simulated accounts. + started atomic.Bool // Flag to indicate if the Simulator has been started. +} + +// NewSimulator initializes a new Simulator with the given context and options. +// It sets up a new Faucet for account management and prepares the Simulator for operation. +// Returns an error if options are not provided or if the Faucet fails to initialize. +func NewSimulator(ctx context.Context, clientPool *clients.ClientPool, opts *Options) (*Simulator, error) { + if opts == nil { + return nil, fmt.Errorf("in order to create a new simulator, you must provide options") + } + + var pool *clients.ClientPool + if clientPool == nil { + emptyPool, err := clients.NewClientPool(ctx, &clients.Options{Nodes: []clients.Node{}}) + if err != nil { + return nil, fmt.Errorf("failed to create simulator client pool: %s", err) + } + pool = emptyPool + } else { + pool = clientPool + } + + faucets, err := NewFaucet(ctx, pool, opts) + if err != nil { + return nil, fmt.Errorf("failed to create new faucet: %s", err) + } + + if opts.FaucetsEnabled { + if opts.FaucetAccountCount <= 0 { + return nil, fmt.Errorf("auto faucet count must be greater than 0") + } + + for _, network := range opts.SupportedNetworks { + if faucetCount := len(faucets.List(network)); faucetCount < opts.FaucetAccountCount { + missingFaucetCount := opts.FaucetAccountCount - faucetCount + + zap.L().Info( + "Generating new faucet accounts. Please be patient...", + zap.Int("current_count", faucetCount), + zap.Int("required_count", opts.FaucetAccountCount), + zap.Int("missing_count", missingFaucetCount), + zap.String("network", string(network)), + ) + + for i := 0; i < missingFaucetCount; i++ { + zap.L().Info( + "Generating new faucet account...", + zap.Int("current_count", i), + zap.Int("required_count", opts.FaucetAccountCount), + zap.String("network", string(network)), + ) + + if _, err := faucets.Create(network, opts.DefaultPassword, true, utils.SimulatorAccountType.String()); err != nil { + return nil, fmt.Errorf("failed to generate faucet account for network: %s err: %s", network, err) + } + } + } + } + } + + return &Simulator{ + ctx: ctx, + opts: opts, + providers: make(map[utils.SimulatorType]Provider), + faucets: faucets, + clientPool: pool, + started: atomic.Bool{}, + }, nil +} + +// GetFaucet returns the Faucet associated with the Simulator. +// The Faucet is used for creating and managing simulated blockchain accounts. +func (s *Simulator) GetFaucet() *Faucet { + return s.faucets +} + +// GetClientPool returns the Ethereum client pool associated with the Simulator. +// The client pool is used for managing Ethereum clients for various simulated environments. +func (s *Simulator) GetClientPool() *clients.ClientPool { + return s.clientPool +} + +func (c *Simulator) GetBindingManager(simType utils.SimulatorType) (*bindings.Manager, error) { + return bindings.NewManager(c.ctx, c.GetProvider(simType).GetClientPool()) +} + +// RegisterProvider registers a new provider for a specific simulation type. +// If a provider for the given name already exists, it returns false. +// Otherwise, it adds the provider and returns true. +func (s *Simulator) RegisterProvider(name utils.SimulatorType, provider Provider) (bool, error) { + if _, ok := s.providers[name]; ok { + return false, fmt.Errorf("provider %s already exists", name) + } + + s.providers[name] = provider + + if anvilProvider, ok := ToAnvilProvider(provider); ok { + manager, err := s.GetBindingManager(name) + if err != nil { + return false, err + } + + anvilProvider.SetBindingManager(manager) + return true, nil + } + + return true, nil +} + +// GetProvider retrieves a registered provider by its simulation type. +// Returns nil if no provider is registered under the given name. +func (s *Simulator) GetProvider(name utils.SimulatorType) Provider { + s.mu.Lock() + defer s.mu.Unlock() + + if provider, ok := s.providers[name]; ok { + return provider + } + + return nil +} + +// GetProviders returns a map of all registered providers by their simulation types. +func (s *Simulator) GetProviders() map[utils.SimulatorType]Provider { + return s.providers +} + +// ProviderExists checks if a provider with the given simulation type is registered. +// Returns true if the provider exists, false otherwise. +func (s *Simulator) ProviderExists(name utils.SimulatorType) bool { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.providers[name]; ok { + return true + } + + return false +} + +// Start initiates the simulation providers within the Simulator. It can start +// all registered providers or a subset specified in the 'simulators' variadic argument. +// If the 'simulators' argument is provided, only the providers matching the specified +// SimulatorTypes are started. If no 'simulators' argument is provided, all registered +// providers are started. +// +// This method iterates through each registered provider and calls its Load method, +// passing the provided context. The Load method of each provider is expected to +// initiate any necessary operations to start the simulation client. +func (s *Simulator) Start(ctx context.Context, simulators ...utils.SimulatorType) error { + for _, provider := range s.providers { + if len(simulators) > 0 { + for _, simulator := range simulators { + if provider.Type() == simulator { + if err := provider.Load(ctx); err != nil { + return fmt.Errorf("failed to start provider: %s", err) + } + } + } + } else { + if err := provider.Load(ctx); err != nil { + return fmt.Errorf("failed to start provider: %s", err) + } + } + } + + s.started.Store(true) + + zap.L().Info("Simulator started successfully") + + return nil +} + +// IsStarted returns a boolean indicating if the Simulator has been started. +func (s *Simulator) IsStarted() bool { + return s.started.Load() +} + +// Stop terminates the simulation providers within the Simulator. Similar to Start, +// it can stop all registered providers or a subset specified in the 'simulators' +// variadic argument. If the 'simulators' argument is provided, only the providers +// matching the specified SimulatorTypes are stopped. If no 'simulators' argument +// is provided, all registered providers are stopped. +// +// This method iterates through each registered provider and calls its Unload method, +// passing the provided context. The Unload method of each provider is expected to +// perform any necessary operations to stop the simulation client. +func (s *Simulator) Stop(ctx context.Context, simulators ...utils.SimulatorType) error { + if !s.IsStarted() { + return fmt.Errorf("simulator has not been started") + } + + for _, provider := range s.providers { + if len(simulators) > 0 { + for _, simulator := range simulators { + if provider.Type() == simulator { + if err := provider.Unload(ctx); err != nil { + return err + } + } + } + } else { + if err := provider.Unload(ctx); err != nil { + return err + } + } + } + + zap.L().Info("Simulator stopped successfully") + + return nil +} + +// Status retrieves the status of the simulation providers within the Simulator. +// Similar to Start and Stop, it can retrieve the status of all registered providers +// or a subset specified in the 'simulators' variadic argument. If the 'simulators' +// argument is provided, only the providers matching the specified SimulatorTypes +// are queried. If no 'simulators' argument is provided, all registered providers +// are queried. +// +// This method iterates through each registered provider and calls its Status method, +// passing the provided context. The Status method of each provider is expected to +// return the current status of the simulation client. +func (s *Simulator) Status(ctx context.Context, simulators ...utils.SimulatorType) (*NodeStatusResponse, error) { + toReturn := &NodeStatusResponse{ + Nodes: make(map[utils.SimulatorType][]*NodeStatus), + } + + for _, provider := range s.providers { + if len(simulators) > 0 { + for _, simulator := range simulators { + if provider.Type() == simulator { + statusSlice, err := provider.Status(ctx) + if err != nil { + return nil, err + } + for _, status := range statusSlice { + toReturn.Nodes[provider.Type()] = append(toReturn.Nodes[provider.Type()], status) + } + } + } + } else { + statusSlice, err := provider.Status(ctx) + if err != nil { + return nil, err + } + for _, status := range statusSlice { + toReturn.Nodes[provider.Type()] = append(toReturn.Nodes[provider.Type()], status) + } + } + } + + return toReturn, nil +} + +// GetClient retrieves a blockchain client for a specific provider and block number. +// It first checks if the provider exists. If not, it returns an error. +// For an existing provider, it attempts to find a node that matches the given block number. +// If such a node doesn't exist, it tries to spawn a new node: +// - It first gets the next available port. If no ports are available, an error is returned. +// - It then starts a new node with the specified start options. +// - If faucets are enabled, it sets up faucet accounts for the new node. +// - Finally, it attempts to retrieve a client for the new node. If not found, an error is reported. +// If a matching node is found, it attempts to retrieve the corresponding client. +// If the client doesn't exist, it returns an error. +// If the provider is recognized but not fully implemented, an appropriate error is returned. +// Returns a pointer to the blockchain client and an error if any issues occur during the process. +func (s *Simulator) GetClient(ctx context.Context, provider utils.SimulatorType, blockNumber *big.Int) (*clients.Client, *Node, error) { + if !s.ProviderExists(provider) { + return nil, nil, fmt.Errorf("provider %s does not exist", provider) + } + + if !s.IsStarted() { + return nil, nil, fmt.Errorf("simulator has not been started") + } + + if providerCtx, ok := s.GetProvider(provider).(*AnvilProvider); ok { + if node, ok := providerCtx.GetNodeByBlockNumber(blockNumber); !ok { + zap.L().Debug( + "Node for block number does not exist. Attempting to spawn new node...", + zap.Any("block_number", blockNumber), + zap.String("provider", provider.String()), + zap.String("network", providerCtx.Network().String()), + ) + + port := providerCtx.GetNextPort() + if port == 0 { + return nil, nil, fmt.Errorf("no available ports to start anvil nodes") + } + + startOpts := StartOptions{ + Fork: providerCtx.opts.Fork, + ForkEndpoint: providerCtx.opts.ForkEndpoint, + Addr: net.TCPAddr{ + IP: providerCtx.opts.IPAddr, + Port: port, + }, + BlockNumber: blockNumber, + } + + newNode, err := providerCtx.Start(ctx, startOpts) + if err != nil { + return nil, nil, fmt.Errorf("failed to spawn anvil node: %s", err) + } + + // Lets now load faucet accounts for the newly spawned node + if providerCtx.simulator.opts.FaucetsEnabled { + if providerCtx.simulator.opts.FaucetsAutoReplenishEnabled { + if err := providerCtx.SetupFaucetAccounts(ctx, newNode); err != nil { + return nil, newNode, fmt.Errorf("failed to load faucet accounts: %s", err) + } + } + } + + providerCtx.mu.Lock() + if _, ok := providerCtx.nodes[newNode.BlockNumber.Uint64()]; !ok { + providerCtx.nodes[newNode.BlockNumber.Uint64()] = newNode + } + providerCtx.mu.Unlock() + + if client, found := providerCtx.GetClientByGroupAndType(newNode.GetProvider().Type(), newNode.GetID().String()); found { + return client, newNode, nil + } else { + return nil, nil, fmt.Errorf( + "client for provider: %s - node %s - block number: %d does not exist", + node.GetProvider(), node.GetID().String(), blockNumber, + ) + } + + } else { + if client, found := providerCtx.GetClientByGroupAndType(node.GetProvider().Type(), node.GetID().String()); found { + return client, node, nil + } else { + return nil, nil, fmt.Errorf("client for provider: %s - node %s does not exist", node.GetProvider(), node.GetID().String()) + } + } + } + + return nil, nil, fmt.Errorf("provider %s is not fully implemented", provider) +} + +// GetOptions returns the Simulator's configuration options. +// The options are used to configure the Simulator's behavior. +func (s *Simulator) GetOptions() *Options { + return s.opts +} + +// Close gracefully shuts down the simulator. It performs the following steps: +// 1. Stops the simulator by calling the Stop method with the simulator's context. +// If stopping the simulator fails, it returns the encountered error. +// 2. Closes the client pool to release all associated resources. +// +// Returns an error if any issues occur during the stopping process, otherwise nil. +func (s *Simulator) Close() error { + if err := s.Stop(s.ctx); err != nil { + return err + } + + s.clientPool.Close() + return nil +} diff --git a/simulator/simulator_test.go b/simulator/simulator_test.go new file mode 100644 index 00000000..5412ef20 --- /dev/null +++ b/simulator/simulator_test.go @@ -0,0 +1,253 @@ +package simulator + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/unpackdev/solgo/clients" + "github.com/unpackdev/solgo/utils" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func TestSimulatorConnectivity(t *testing.T) { + tAssert := assert.New(t) + + config := zap.NewDevelopmentConfig() + config.Level = zap.NewAtomicLevelAt(zap.DebugLevel) + config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + logger, err := config.Build() + tAssert.NoError(err) + zap.ReplaceGlobals(logger) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + simulator, err := CreateNewTestSimulator(ctx, nil, t, nil) + require.NoError(t, err) + require.NotNil(t, simulator) + defer simulator.Close() + + testCases := []struct { + name string + provider utils.SimulatorType + expectErr bool + }{ + { + name: "Anvil simulator start and stop with periodic status checks", + provider: utils.AnvilSimulator, + expectErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + err := simulator.Start(ctx) + if tc.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + for i := 0; i < 2; i++ { + statuses, err := simulator.Status(ctx) + if tc.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + tAssert.NotNil(statuses) + } + + anvilStatuses, found := statuses.GetNodesByType(utils.AnvilSimulator) + tAssert.NotNil(anvilStatuses) + tAssert.True(found) + tAssert.Exactly(1, len(anvilStatuses)) + + time.Sleep(300 * time.Millisecond) + } + + err = simulator.Stop(ctx) + if tc.expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestAnvilSimulator(t *testing.T) { + tAssert := assert.New(t) + + config := zap.NewDevelopmentConfig() + config.Level = zap.NewAtomicLevelAt(zap.DebugLevel) + config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + logger, err := config.Build() + tAssert.NoError(err) + zap.ReplaceGlobals(logger) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + clientOptions := &clients.Options{ + Nodes: []clients.Node{ + { + Group: string(utils.Ethereum), + Type: "mainnet", + Endpoint: "https://ethereum.publicnode.com", + NetworkId: 1, + ConcurrentClientsNumber: 1, + }, + }, + } + + pool, err := clients.NewClientPool(ctx, clientOptions) + tAssert.NoError(err) + tAssert.NotNil(pool) + + simulator, err := CreateNewTestSimulator(ctx, nil, t, nil) + require.NoError(t, err) + require.NotNil(t, simulator) + defer simulator.Close() + + err = simulator.Start(ctx) + require.NoError(t, err) + + defer func() { + err := simulator.Stop(ctx) + require.NoError(t, err) + }() + + testCases := []struct { + name string + provider utils.SimulatorType + expectErr bool + testFunc func(t *testing.T, simulator *Simulator, name string, provider utils.SimulatorType, expectErr bool) + }{ + { + name: "Anvil simulator periodic status checks", + provider: utils.AnvilSimulator, + expectErr: false, + testFunc: func(t *testing.T, simulator *Simulator, name string, provider utils.SimulatorType, expectErr bool) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + for i := 0; i < 2; i++ { + statuses, err := simulator.Status(ctx) + if expectErr { + require.Error(t, err) + } else { + require.NoError(t, err) + tAssert.NotNil(statuses) + } + + anvilStatuses, found := statuses.GetNodesByType(utils.AnvilSimulator) + tAssert.NotNil(anvilStatuses) + tAssert.True(found) + tAssert.Exactly(1, len(anvilStatuses)) + + time.Sleep(100 * time.Millisecond) + } + + }, + }, + { + name: "Get anvil client from latest block", + provider: utils.AnvilSimulator, + expectErr: false, + testFunc: func(t *testing.T, simulator *Simulator, name string, provider utils.SimulatorType, expectErr bool) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + client := pool.GetClientByGroup(string(utils.Ethereum)) + require.NotNil(t, client) + + latestBlock, err := client.HeaderByNumber(ctx, nil) + require.NoError(t, err) + require.NotNil(t, latestBlock) + + simulatorClient, node, err := simulator.GetClient(ctx, utils.AnvilSimulator, latestBlock.Number) + if expectErr { + require.Error(t, err) + require.Nil(t, simulatorClient) + require.Nil(t, node) + } else { + require.NoError(t, err) + require.NotNil(t, simulatorClient) + require.NotNil(t, node) + } + + // Just for testing purpose lets fetch from each faucet account balance at latest block + for _, account := range simulator.GetFaucet().List(utils.AnvilNetwork) { + balance, err := simulatorClient.BalanceAt(ctx, account.GetAddress(), nil) + require.NoError(t, err) + require.NotNil(t, balance) + } + + anvilProvider, found := ToAnvilProvider(simulator.GetProvider(utils.AnvilSimulator)) + require.True(t, found) + require.NotNil(t, anvilProvider) + + // Some random etherscan address... (Have no affiliation with it) + randomAddress := common.HexToAddress("0x235eE805F962690254e9a440E01574376136ecb1") + + impersonatedAddr, err := anvilProvider.ImpersonateAccount(randomAddress) + require.NoError(t, err) + require.Equal(t, randomAddress, impersonatedAddr) + + impersonatedAddr, err = anvilProvider.StopImpersonateAccount(randomAddress) + require.NoError(t, err) + require.Equal(t, randomAddress, impersonatedAddr) + }, + }, + { + name: "Attempt to impersonate account and to stop impersonating account", + provider: utils.AnvilSimulator, + expectErr: false, + testFunc: func(t *testing.T, simulator *Simulator, name string, provider utils.SimulatorType, expectErr bool) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + client := pool.GetClientByGroup(string(utils.Ethereum)) + require.NotNil(t, client) + + latestBlock, err := client.HeaderByNumber(ctx, nil) + require.NoError(t, err) + require.NotNil(t, latestBlock) + + simulatorClient, node, err := simulator.GetClient(ctx, utils.AnvilSimulator, latestBlock.Number) + if expectErr { + require.Error(t, err) + require.Nil(t, simulatorClient) + require.Nil(t, node) + } else { + require.NoError(t, err) + require.NotNil(t, simulatorClient) + require.NotNil(t, node) + } + + // Just for testing purpose lets fetch from each faucet account balance at latest block + for _, account := range simulator.GetFaucet().List(utils.AnvilNetwork) { + balance, err := simulatorClient.BalanceAt(ctx, account.GetAddress(), nil) + require.NoError(t, err) + require.NotNil(t, balance) + fmt.Println("Balance", balance) + } + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.testFunc(t, simulator, tc.name, tc.provider, tc.expectErr) + }) + } +} diff --git a/simulator/status.go b/simulator/status.go new file mode 100644 index 00000000..e086956b --- /dev/null +++ b/simulator/status.go @@ -0,0 +1,46 @@ +package simulator + +import ( + "github.com/google/uuid" + "github.com/unpackdev/solgo/utils" +) + +// NodeStatusType defines the possible states of a simulation node. +type NodeStatusType string + +const ( + // NodeStatusTypeError indicates that the simulation node is in an error state. + NodeStatusTypeError NodeStatusType = "error" + + // NodeStatusTypeRunning indicates that the simulation node is currently running. + NodeStatusTypeRunning NodeStatusType = "running" + + // NodeStatusTypeStopped indicates that the simulation node has been stopped. + NodeStatusTypeStopped NodeStatusType = "stopped" +) + +// NodeStatus represents the status of a simulation node, including its unique identifier, +// IP address, port, and operational state. +type NodeStatus struct { + ID uuid.UUID `json:"id"` // Unique identifier for the node. + BlockNumber uint64 `json:"block_number"` // Block number at which the node is currently operating. + IPAddr string `json:"ip_addr"` // IP address of the node. + Port int `json:"port"` // Port on which the node is running. + Success bool `json:"success"` // Indicates whether the node is operating successfully. + Status NodeStatusType `json:"status"` // Current status of the node. + Error error `json:"error"` // Error encountered by the node, if any. +} + +// NodeStatusResponse contains a mapping of node statuses categorized by simulator type. +type NodeStatusResponse struct { + Nodes map[utils.SimulatorType][]*NodeStatus `json:"nodes"` // Mapping of simulator types to their respective node statuses. +} + +// GetNodesByType returns a slice of NodeStatus pointers for a given simulator type. +func (nr *NodeStatusResponse) GetNodesByType(simType utils.SimulatorType) ([]*NodeStatus, bool) { + if nodes, ok := nr.Nodes[simType]; ok { + return nodes, true + } + + return nil, false +} diff --git a/simulator/test_helpers.go b/simulator/test_helpers.go new file mode 100644 index 00000000..0bdc6e30 --- /dev/null +++ b/simulator/test_helpers.go @@ -0,0 +1,80 @@ +package simulator + +import ( + "context" + "math/big" + "net" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/unpackdev/solgo/clients" + "github.com/unpackdev/solgo/utils" +) + +// CreateNewTestSimulator initializes and configures a new Simulator instance for testing purposes. +// It performs the following steps: +// 1. Determines the current working directory and verifies it's not empty. +// 2. Sets up the keystore path, which is assumed to be one level up from the current directory. +// 3. Establishes a base simulator with predefined options such as keystore path, supported networks, +// faucet configurations, and a default password for the accounts. +// 4. Creates a new AnvilProvider instance with specified options including network settings, +// client count limits, process ID path, executable path, and port range. +// 5. Registers the AnvilProvider with the newly created simulator. +// +// Returns the initialized Simulator instance and an error if any occurs during the setup process. +// This function utilizes the 'assert' and 'require' packages from 'testify' to ensure that each setup step is successful. +func CreateNewTestSimulator(ctx context.Context, clientPool *clients.ClientPool, t *testing.T, opts *AnvilProviderOptions) (*Simulator, error) { + tAssert := assert.New(t) + + // Get the current working directory + cwd, err := os.Getwd() + tAssert.NoError(err) + tAssert.NotEmpty(cwd) + + // Navigate up one level + keystorePath := filepath.Join(filepath.Dir(cwd), "data", "faucets") + + // Establish base simulator + // It acts as a faucet provider and manager for all the simulation providers. + // It also provides a way to manage the simulation providers. + simulator, err := NewSimulator(ctx, clientPool, &Options{ + KeystorePath: keystorePath, + SupportedNetworks: []utils.Network{utils.Ethereum, utils.AnvilNetwork}, + FaucetsEnabled: true, + FaucetAccountCount: 10, + FaucetAccountDefaultBalance: new(big.Int).Mul(big.NewInt(1000), new(big.Int).Exp(big.NewInt(10), big.NewInt(18), nil)), + DefaultPassword: "wearegoingtogethacked", + }) + + require.NoError(t, err) + tAssert.NotNil(simulator) + + if opts == nil { + opts = &AnvilProviderOptions{ + Network: utils.AnvilNetwork, + NetworkID: utils.EthereumNetworkID, + ClientCount: 1, + MaxClientCount: 10, + AutoImpersonate: true, + PidPath: filepath.Join("/", "tmp", ".solgo", "/", "simulator", "/", "anvil"), + AnvilExecutablePath: os.Getenv("SOLGO_ANVIL_PATH"), + Fork: true, + ForkEndpoint: os.Getenv("SOLGO_SIMULATOR_FORK_ENDPOINT"), + IPAddr: net.ParseIP("127.0.0.1"), + StartPort: 5400, + EndPort: 5500, + } + } + + anvilProvider, err := NewAnvilProvider(ctx, simulator, opts) + + require.NoError(t, err) + tAssert.NotNil(anvilProvider) + + simulator.RegisterProvider(utils.AnvilSimulator, anvilProvider) + + return simulator, nil +}