diff --git a/.github/workflows/seth-test.yml b/.github/workflows/seth-test.yml index a561324c2..dad61971f 100644 --- a/.github/workflows/seth-test.yml +++ b/.github/workflows/seth-test.yml @@ -58,12 +58,12 @@ jobs: network-type: Anvil url: "http://localhost:8545" extra_flags: "''" - - regex: "'TestContractMap|TestGasEstimator|TestRPCHealthCheck|TestUtil|TestContract'" + - regex: "'TestContractMap|TestGasEstimator|TestRPCHealthCheck|TestUtil|TestContract|TestGasAdjuster'" network-type: Geth url: "ws://localhost:8546" extra_flags: "-race" # TODO: still expects Geth WS URL for some reason - - regex: "'TestContractMap|TestGasEstimator|TestRPCHealthCheck|TestUtil|TestContract'" + - regex: "'TestContractMap|TestGasEstimator|TestRPCHealthCheck|TestUtil|TestContract|TestGasAdjuster'" network-type: Anvil url: "http://localhost:8545" extra_flags: "-race" diff --git a/lib/utils/seth/seth.go b/lib/utils/seth/seth.go index dc8d21083..0614fd797 100644 --- a/lib/utils/seth/seth.go +++ b/lib/utils/seth/seth.go @@ -1,11 +1,11 @@ package seth import ( + "errors" "fmt" "regexp" "strings" - "github.com/pkg/errors" "github.com/rs/zerolog" pkg_seth "github.com/smartcontractkit/chainlink-testing-framework/seth" @@ -112,27 +112,29 @@ func GetChainClient(c config.SethConfig, network blockchain.EVMNetwork) (*pkg_se func GetChainClientWithConfigFunction(c config.SethConfig, network blockchain.EVMNetwork, configFn ConfigFunction) (*pkg_seth.Client, error) { readSethCfg := c.GetSethConfig() if readSethCfg == nil { - return nil, errors.New("Seth config not found") + return nil, fmt.Errorf("Seth config not found in the provided configuration. " + + "Ensure your TOML config file has a [Seth] section with required settings. " + + "See example: https://github.com/smartcontractkit/chainlink-testing-framework/blob/main/seth/seth.toml") } sethCfg, err := MergeSethAndEvmNetworkConfigs(network, *readSethCfg) if err != nil { - return nil, errors.Wrapf(err, "Error merging seth and evm network configs") + return nil, fmt.Errorf("error merging seth and evm network configs: %w", err) } err = configFn(&sethCfg) if err != nil { - return nil, errors.Wrapf(err, "Error applying seth config function") + return nil, fmt.Errorf("error applying seth config function: %w", err) } err = ValidateSethNetworkConfig(sethCfg.Network) if err != nil { - return nil, errors.Wrapf(err, "Error validating seth network config") + return nil, fmt.Errorf("error validating seth network config: %w", err) } chainClient, err := pkg_seth.NewClientWithConfig(&sethCfg) if err != nil { - return nil, errors.Wrapf(err, "Error creating seth client") + return nil, fmt.Errorf("error creating seth client: %w", err) } return chainClient, nil @@ -255,33 +257,47 @@ func MustReplaceSimulatedNetworkUrlWithK8(l zerolog.Logger, network blockchain.E // ValidateSethNetworkConfig validates the Seth network config func ValidateSethNetworkConfig(cfg *pkg_seth.Network) error { if cfg == nil { - return errors.New("network cannot be nil") + return fmt.Errorf("network configuration cannot be nil. " + + "Ensure your Seth config has properly configured network settings") } if len(cfg.URLs) == 0 { - return errors.New("URLs are required") + return fmt.Errorf("network URLs are required. " + + "Add RPC endpoint URLs in the 'urls_secret' field of your network config") } if len(cfg.PrivateKeys) == 0 { - return errors.New("PrivateKeys are required") + return fmt.Errorf("private keys are required. " + + "Add at least one private key in 'private_keys_secret' or via environment variables") } if cfg.TransferGasFee == 0 { - return errors.New("TransferGasFee needs to be above 0. It's the gas fee for a simple transfer transaction") + return fmt.Errorf("transfer_gas_fee must be greater than 0. " + + "This is the gas fee for a simple transfer transaction. " + + "Set 'transfer_gas_fee' in your network config") } if cfg.TxnTimeout.Duration() == 0 { - return errors.New("TxnTimeout needs to be above 0. It's the timeout for a transaction") + return fmt.Errorf("transaction timeout must be greater than 0. " + + "Set 'txn_timeout' in your network config (e.g., '30s', '1m')") } if cfg.EIP1559DynamicFees { if cfg.GasFeeCap == 0 { - return errors.New("GasFeeCap needs to be above 0. It's the maximum fee per gas for a transaction (including tip)") + return fmt.Errorf("gas_fee_cap must be greater than 0 for EIP-1559 transactions. " + + "This is the maximum fee per gas (base fee + tip). " + + "Set 'gas_fee_cap' in your network config") } if cfg.GasTipCap == 0 { - return errors.New("GasTipCap needs to be above 0. It's the maximum tip per gas for a transaction") + return fmt.Errorf("gas_tip_cap must be greater than 0 for EIP-1559 transactions. " + + "This is the maximum priority fee per gas. " + + "Set 'gas_tip_cap' in your network config") } if cfg.GasFeeCap <= cfg.GasTipCap { - return errors.New("GasFeeCap needs to be above GasTipCap (as it is base fee + tip cap)") + return fmt.Errorf("gas_fee_cap (%d) must be greater than gas_tip_cap (%d). "+ + "Fee cap should be base fee + tip cap. "+ + "Adjust your network config accordingly", + cfg.GasFeeCap, cfg.GasTipCap) } } else { if cfg.GasPrice == 0 { - return errors.New("GasPrice needs to be above 0. It's the price of gas for a transaction") + return fmt.Errorf("gas_price must be greater than 0 for legacy transactions. " + + "Set 'gas_price' in your network config") } } diff --git a/seth/.changeset/v1.51.4.md b/seth/.changeset/v1.51.4.md new file mode 100644 index 000000000..180ef5c5f --- /dev/null +++ b/seth/.changeset/v1.51.4.md @@ -0,0 +1 @@ +- Reviewed error messages, made them easier to understand and more actionable. Removed "github.com/pkg/error" usage. \ No newline at end of file diff --git a/seth/.tool-versions b/seth/.tool-versions new file mode 100644 index 000000000..5f9bce3ec --- /dev/null +++ b/seth/.tool-versions @@ -0,0 +1 @@ +golangci-lint 1.64.5 \ No newline at end of file diff --git a/seth/abi_finder.go b/seth/abi_finder.go index b7316c442..e79bdd87f 100644 --- a/seth/abi_finder.go +++ b/seth/abi_finder.go @@ -1,11 +1,11 @@ package seth import ( + "fmt" "strings" "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" - "github.com/pkg/errors" ) type ABIFinder struct { @@ -46,12 +46,22 @@ func (a *ABIFinder) FindABIByMethod(address string, signature []byte) (ABIFinder contractName := a.ContractMap.GetContractName(address) abiInstanceCandidate, ok := a.ContractStore.ABIs[contractName+".abi"] if !ok { - err := errors.New(ErrNoAbiFound) + err := fmt.Errorf("no ABI found for contract '%s' at address %s, even though it's registered in the contract map. "+ + "This happens when:\n"+ + " 1. Contract address is in the contract map but ABI file is missing from abi_dir\n"+ + " 2. ABI files were moved or deleted after contract deployment\n"+ + " 3. Contract map is corrupted or out of sync\n"+ + "Troubleshooting:\n"+ + " 1. Verify ABI file '%s.abi' exists in the configured abi_dir\n"+ + " 2. Check if save_deployed_contracts_map = true in config\n"+ + " 3. Re-deploy the contract or manually add ABI with ContractStore.AddABI()\n"+ + " 4. For external contracts, obtain and add the ABI manually", + contractName, address, contractName) L.Err(err). Str("Contract", contractName). Str("Address", address). - Msg("ABI not found, even though contract is known. This should not happen. Contract map might be corrupted") - return ABIFinderResult{}, err + Msg("ABI not found for known contract") + return ABIFinderResult{}, fmt.Errorf("%w: %v", ErrNoABIMethod, err) } methodCandidate, err := abiInstanceCandidate.MethodById(signature) @@ -89,7 +99,7 @@ func (a *ABIFinder) FindABIByMethod(address string, signature []byte) (ABIFinder Str("Supposed address", address). Msg("Method not found in known ABI instance. This should not happen. Contract map might be corrupted") - return ABIFinderResult{}, err + return ABIFinderResult{}, fmt.Errorf("%w: %v", ErrNoABIMethod, err) } result.Method = methodCandidate @@ -127,7 +137,22 @@ func (a *ABIFinder) FindABIByMethod(address string, signature []byte) (ABIFinder } if result.Method == nil { - return ABIFinderResult{}, errors.New(ErrNoABIMethod) + err := fmt.Errorf("no ABI found with method signature %s for contract at address %s.\n"+ + "Checked %d ABIs but none matched.\n"+ + "Possible causes:\n"+ + " 1. Contract ABI not loaded (check abi_dir and contract_map_file)\n"+ + " 2. Method signature doesn't match any function in loaded ABIs\n"+ + " 3. Contract address not registered in contract map\n"+ + " 4. Wrong contract address (check deployment logs)\n"+ + "Troubleshooting:\n"+ + " 1. Verify contract was deployed with DeployContract() or loaded with LoadContract()\n"+ + " 2. Check the method signature is correct (case-sensitive, including parameter types)\n"+ + " 3. Ensure ABI file exists in the directory specified by 'abi_dir'\n"+ + " 4. Review contract_map_file for address-to-name mappings\n"+ + " 5. Use ContractStore.AddABI() to manually add the ABI", + stringSignature, address, len(a.ContractStore.ABIs)) + + return ABIFinderResult{}, fmt.Errorf("%w: %v", ErrNoABIMethod, err) } return result, nil diff --git a/seth/block_stats.go b/seth/block_stats.go index db883331c..30c1ca54e 100644 --- a/seth/block_stats.go +++ b/seth/block_stats.go @@ -48,7 +48,14 @@ func (cs *BlockStats) Stats(startBlock *big.Int, endBlock *big.Int) error { if endBlock == nil || startBlock.Sign() < 0 { header, err := cs.Client.Client.HeaderByNumber(context.Background(), nil) if err != nil { - return fmt.Errorf("failed to get the latest block header: %v", err) + return fmt.Errorf("failed to get the latest block header for block stats: %w\n"+ + "This indicates RPC connectivity issues.\n"+ + "Troubleshooting:\n"+ + " 1. Verify RPC endpoint is accessible\n"+ + " 2. Check network connectivity\n"+ + " 3. Ensure the node is synced\n"+ + " 4. Try increasing dial_timeout in config", + err) } latestBlockNumber = header.Number } @@ -62,7 +69,13 @@ func (cs *BlockStats) Stats(startBlock *big.Int, endBlock *big.Int) error { endBlock = latestBlockNumber } if endBlock != nil && startBlock.Int64() > endBlock.Int64() { - return fmt.Errorf("start block is less than the end block") + return fmt.Errorf("invalid block range for statistics: start block %d > end block %d.\n"+ + "This is a bug in Seth's block stats calculation logic.\n"+ + "Please open a GitHub issue at https://github.com/smartcontractkit/chainlink-testing-framework/issues with:\n"+ + " 1. Your configuration file\n"+ + " 2. The operation you were performing\n"+ + " 3. Network name and chain ID", + startBlock.Int64(), endBlock.Int64()) } L.Info(). Int64("EndBlock", endBlock.Int64()). @@ -107,7 +120,12 @@ func (cs *BlockStats) Stats(startBlock *big.Int, endBlock *big.Int) error { // CalculateBlockDurations calculates and logs the duration, TPS, gas used, and gas limit between each consecutive block func (cs *BlockStats) CalculateBlockDurations(blocks []*types.Block) error { if len(blocks) == 0 { - return fmt.Errorf("no blocks no analyze") + return fmt.Errorf("no block data available for duration analysis. " + + "This happens when:\n" + + " 1. No blocks were provided for analysis\n" + + " 2. All block fetch attempts failed\n" + + " 3. Block range is invalid\n" + + "Check RPC connectivity and ensure blocks exist in the specified range") } var ( durations []time.Duration diff --git a/seth/client.go b/seth/client.go index 35dcb590c..0a17db347 100644 --- a/seth/client.go +++ b/seth/client.go @@ -3,6 +3,7 @@ package seth import ( "context" "crypto/ecdsa" + "errors" "fmt" "math/big" "net/http" @@ -21,33 +22,17 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" - "github.com/pkg/errors" "github.com/rs/zerolog" "golang.org/x/sync/errgroup" ) const ( ErrEmptyConfigPath = "toml config path is empty, set SETH_CONFIG_PATH" - ErrCreateABIStore = "failed to create ABI store" - ErrReadingKeys = "failed to read keys" - ErrCreateNonceManager = "failed to create nonce manager" - ErrCreateTracer = "failed to create tracer" - ErrReadContractMap = "failed to read deployed contract map" - ErrRpcHealthCheckFailed = "RPC health check failed ¯\\_(ツ)_/¯" ErrContractDeploymentFailed = "contract deployment failed" - ErrNoPksEphemeralMode = "no private keys loaded, cannot fund ephemeral addresses" + // unused by Seth, but used by upstream ErrNoKeyLoaded = "failed to load private key" - ErrSethConfigIsNil = "seth config is nil" - ErrNetworkIsNil = "no Network is set in the Seth config" - ErrNonceManagerConfigIsNil = "nonce manager config is nil" - ErrReadOnlyWithPrivateKeys = "read-only mode is enabled, but you tried to load private keys" - ErrReadOnlyEphemeralKeys = "ephemeral mode is not supported in read-only mode" - ErrReadOnlyGasBumping = "gas bumping is not supported in read-only mode" - ErrReadOnlyRpcHealth = "RPC health check is not supported in read-only mode" - ErrReadOnlyPendingNonce = "pending nonce protection is not supported in read-only mode" - ContractMapFilePattern = "deployed_contracts_%s_%s.toml" RevertedTransactionsFilePattern = "reverted_transactions_%s_%s.json" ) @@ -89,7 +74,9 @@ func NewClientWithConfig(cfg *Config) (*Client, error) { initDefaultLogging() if cfg == nil { - return nil, errors.New(ErrSethConfigIsNil) + return nil, fmt.Errorf("seth configuration is nil. " + + "Ensure you're calling NewClientWithConfig() with a valid config, or use NewClient() to load from SETH_CONFIG_PATH environment variable. " + + "See documentation for configuration examples") } if cfgErr := cfg.Validate(); cfgErr != nil { return nil, cfgErr @@ -100,7 +87,13 @@ func NewClientWithConfig(cfg *Config) (*Client, error) { cfg.setEphemeralAddrs() cs, err := NewContractStore(filepath.Join(cfg.ConfigDir, cfg.ABIDir), filepath.Join(cfg.ConfigDir, cfg.BINDir), cfg.GethWrappersDirs) if err != nil { - return nil, errors.Wrap(err, ErrCreateABIStore) + return nil, fmt.Errorf("failed to create ABI/contract store: %w\n"+ + "Check that:\n"+ + " 1. 'abi_dir' path is correct (current: %s)\n"+ + " 2. 'bin_dir' path is correct (current: %s)\n"+ + " 3. These directories exist and are readable\n"+ + " 4. Or comment out these settings if not using ABI/BIN files", + err, cfg.ABIDir, cfg.BINDir) } if cfg.ephemeral { // we don't care about any other keys, only the root key @@ -117,11 +110,16 @@ func NewClientWithConfig(cfg *Config) (*Client, error) { } addrs, pkeys, err := cfg.ParseKeys() if err != nil { - return nil, errors.Wrap(err, ErrReadingKeys) + return nil, fmt.Errorf("failed to parse private keys: %w\n"+ + "Ensure private keys are valid hex strings (64 characters, without 0x prefix). "+ + "Keys should be set in SETH_ROOT_PRIVATE_KEY env var or 'private_keys_secret' in seth.toml", + err) } nm, err := NewNonceManager(cfg, addrs, pkeys) if err != nil { - return nil, errors.Wrap(err, ErrCreateNonceManager) + return nil, fmt.Errorf("failed to create nonce manager: %w\n"+ + "This is usually a configuration issue. Check 'nonce_manager' settings in your config (seth.toml or ClientBuilder)", + err) } if !cfg.IsSimulatedNetwork() && cfg.SaveDeployedContractsMap && cfg.ContractMapFile == "" { @@ -135,7 +133,9 @@ func NewClientWithConfig(cfg *Config) (*Client, error) { if !cfg.IsSimulatedNetwork() { contractAddressToNameMap.addressMap, err = LoadDeployedContracts(cfg.ContractMapFile) if err != nil { - return nil, errors.Wrap(err, ErrReadContractMap) + return nil, fmt.Errorf("failed to load deployed contracts map from '%s': %w\n"+ + "If this is a fresh deployment or you don't need contract mapping, ignore this error by setting save_deployed_contracts_map = false", + cfg.ContractMapFile, err) } } else { L.Debug().Msg("Simulated network, contract map won't be read from file") @@ -150,7 +150,15 @@ func NewClientWithConfig(cfg *Config) (*Client, error) { if (cfg.ethclient != nil && shouldInitializeTracer(cfg.ethclient, cfg) && len(cfg.Network.URLs) > 0) || cfg.ethclient == nil { tr, err := NewTracer(cs, &abiFinder, cfg, contractAddressToNameMap, addrs) if err != nil { - return nil, errors.Wrap(err, ErrCreateTracer) + return nil, fmt.Errorf("failed to create transaction tracer: %w\n"+ + "Possible causes:\n"+ + " 1. RPC endpoint is not accessible\n"+ + " 2. RPC node doesn't support debug_traceTransaction API\n"+ + "Solutions:\n"+ + " 1. Verify RPC endpoint URL is correct and accessible\n"+ + " 2. Use an RPC node with debug API enabled (archive node recommended)\n"+ + " 3. Set tracing_level = 'NONE' in config to disable tracing", + err) } opts = append(opts, WithTracer(tr)) } @@ -182,13 +190,19 @@ func NewClientRaw( opts ...ClientOpt, ) (*Client, error) { if cfg == nil { - return nil, errors.New(ErrSethConfigIsNil) + return nil, fmt.Errorf("seth configuration is nil. " + + "Provide a valid Config when calling NewClientRaw(). " + + "Consider using NewClient() or NewClientWithConfig() instead") } if cfgErr := cfg.Validate(); cfgErr != nil { return nil, cfgErr } if cfg.ReadOnly && (len(addrs) > 0 || len(pkeys) > 0) { - return nil, errors.New(ErrReadOnlyWithPrivateKeys) + return nil, fmt.Errorf("configuration conflict: read-only mode is enabled, but private keys were provided. " + + "Read-only mode is for querying blockchain state only (no transactions).\n" + + "To fix:\n" + + " 1. Remove private keys if you only need to read data\n" + + " 2. Set 'read_only = false' in config if you need to send transactions") } var firstUrl string @@ -196,7 +210,8 @@ func NewClientRaw( if cfg.ethclient == nil { L.Info().Msg("Creating new ethereum client") if len(cfg.Network.URLs) == 0 { - return nil, errors.New("no RPC URL provided") + return nil, fmt.Errorf("no RPC URLs provided. " + + "Set RPC URLs in your seth.toml config under 'urls_secret = [\"http://...\"]' or provide via WithRpcUrl() when using ClientBuilder") } if len(cfg.Network.URLs) > 1 { @@ -213,7 +228,13 @@ func NewClientRaw( }), ) if err != nil { - return nil, fmt.Errorf("failed to connect RPC client to '%s' due to: %w", cfg.MustFirstNetworkURL(), err) + return nil, fmt.Errorf("failed to connect to RPC endpoint '%s': %w\n"+ + "Troubleshooting steps:\n"+ + " 1. Verify the URL is correct and accessible\n"+ + " 2. Check if the RPC node is running\n"+ + " 3. Verify network connectivity and firewall rules\n"+ + " 4. Check if dial_timeout (%s) is sufficient for your network", + cfg.MustFirstNetworkURL(), err, cfg.Network.DialTimeout.String()) } client = ethclient.NewClient(rpcClient) firstUrl = cfg.MustFirstNetworkURL() @@ -243,7 +264,10 @@ func NewClientRaw( if cfg.Network.ChainID == 0 { chainId, err := c.Client.ChainID(context.Background()) if err != nil { - return nil, errors.Wrap(err, "failed to get chain ID") + return nil, fmt.Errorf("failed to get chain ID from RPC: %w\n"+ + "Ensure the RPC endpoint is accessible and the network is running. "+ + "You can also set 'chain_id' explicitly in your seth.toml to avoid this check", + err) } cfg.Network.ChainID = chainId.Uint64() c.ChainID = mustSafeInt64(cfg.Network.ChainID) @@ -256,7 +280,9 @@ func NewClientRaw( if !cfg.IsSimulatedNetwork() { c.ContractAddressToNameMap.addressMap, err = LoadDeployedContracts(cfg.ContractMapFile) if err != nil { - return nil, errors.Wrap(err, ErrReadContractMap) + return nil, fmt.Errorf("failed to load deployed contracts map from '%s': %w\n"+ + "If this is a fresh deployment or you don't need contract mapping, ignore this error by setting save_deployed_contracts_map = false", + cfg.ContractMapFile, err) } if len(c.ContractAddressToNameMap.addressMap) > 0 { L.Info(). @@ -288,7 +314,10 @@ func NewClientRaw( if cfg.CheckRpcHealthOnStart { if cfg.ReadOnly { - return nil, errors.New(ErrReadOnlyRpcHealth) + return nil, fmt.Errorf("RPC health check is not supported in read-only mode because it requires sending transactions. " + + "Either:\n" + + " 1. Set 'read_only = false' to enable transaction capabilities\n" + + " 2. Set 'check_rpc_health_on_start = false' to skip the health check") } if c.NonceManager == nil { L.Debug().Msg("Nonce manager is not set, RPC health check will be skipped. Client will most probably fail on first transaction") @@ -300,7 +329,10 @@ func NewClientRaw( } if cfg.PendingNonceProtectionEnabled && cfg.ReadOnly { - return nil, errors.New(ErrReadOnlyPendingNonce) + return nil, fmt.Errorf("pending nonce protection is not supported in read-only mode because it requires transaction monitoring. " + + "Either:\n" + + " 1. Set 'read_only = false' to enable transaction capabilities\n" + + " 2. Set 'pending_nonce_protection_enabled = false'") } cfg.setEphemeralAddrs() @@ -315,10 +347,15 @@ func NewClientRaw( if cfg.ephemeral { if len(c.Addresses) == 0 { - return nil, errors.New(ErrNoPksEphemeralMode) + return nil, fmt.Errorf("ephemeral mode requires exactly one root private key to fund ephemeral addresses, but no keys were loaded. " + + "Load the root private key via:\n" + + " 1. SETH_ROOT_PRIVATE_KEY environment variable\n" + + " 2. 'root_private_key' in seth.toml\n" + + " 3. WithPrivateKeys() when using ClientBuilder") } if cfg.ReadOnly { - return nil, errors.New(ErrReadOnlyEphemeralKeys) + return nil, fmt.Errorf("ephemeral mode is not supported in read-only mode because it requires funding transactions. " + + "Set 'read_only = false' or disable ephemeral mode by removing 'ephemeral_addresses_number' from config") } ctx, cancel := context.WithTimeout(context.Background(), c.Cfg.Network.TxnTimeout.D) defer cancel() @@ -338,7 +375,11 @@ func NewClientRaw( // root key is element 0 in ephemeral for _, addr := range c.Addresses[1:] { eg.Go(func() error { - return c.TransferETHFromKey(egCtx, 0, addr.Hex(), bd.AddrFunding, gasPrice) + err := c.TransferETHFromKey(egCtx, 0, addr.Hex(), bd.AddrFunding, gasPrice) + if err != nil { + return fmt.Errorf("failed to fund ephemeral address %s: %w", addr.Hex(), err) + } + return nil }) } if err := eg.Wait(); err != nil { @@ -352,7 +393,9 @@ func NewClientRaw( if c.ContractStore == nil { cs, err := NewContractStore(filepath.Join(cfg.ConfigDir, cfg.ABIDir), filepath.Join(cfg.ConfigDir, cfg.BINDir), cfg.GethWrappersDirs) if err != nil { - return nil, errors.Wrap(err, ErrCreateABIStore) + return nil, fmt.Errorf("failed to create contract store for tracing: %w\n"+ + "Tracing requires ABI files. Check 'abi_dir' configuration or disable tracing with tracing_level = 'NONE'", + err) } c.ContractStore = cs } @@ -362,7 +405,15 @@ func NewClientRaw( } tr, err := NewTracer(c.ContractStore, c.ABIFinder, cfg, c.ContractAddressToNameMap, addrs) if err != nil { - return nil, errors.Wrap(err, ErrCreateTracer) + return nil, fmt.Errorf("failed to create transaction tracer: %w\n"+ + "Possible causes:\n"+ + " 1. RPC endpoint is not accessible\n"+ + " 2. RPC node doesn't support debug_traceTransaction API\n"+ + "Solutions:\n"+ + " 1. Verify RPC endpoint URL is correct and accessible\n"+ + " 2. Use an RPC node with debug API enabled (archive node recommended)\n"+ + " 3. Set tracing_level = 'NONE' in config to disable tracing", + err) } c.Tracer = tr @@ -389,7 +440,10 @@ func NewClientRaw( } if c.Cfg.GasBump != nil && c.Cfg.GasBump.Retries != 0 && c.Cfg.ReadOnly { - return nil, errors.New(ErrReadOnlyGasBumping) + return nil, fmt.Errorf("gas bumping is not supported in read-only mode because it requires sending replacement transactions. " + + "Either:\n" + + " 1. Set 'read_only = false' to enable transaction capabilities\n" + + " 2. Set 'gas_bump.retries = 0' to disable gas bumping") } // if gas bumping is enabled, but no strategy is set, we set the default one; otherwise we set the no-op strategy (defensive programming to avoid NPE) @@ -420,7 +474,15 @@ func (m *Client) checkRPCHealth() error { err = m.TransferETHFromKey(ctx, 0, m.Addresses[0].Hex(), big.NewInt(10_000), gasPrice) if err != nil { - return errors.Wrap(err, ErrRpcHealthCheckFailed) + return fmt.Errorf("RPC health check failed: %w\n"+ + "The health check sends a small self-transfer transaction to verify RPC functionality.\n"+ + "Possible issues:\n"+ + " 1. RPC node is not accepting transactions\n"+ + " 2. Root key has insufficient balance\n"+ + " 3. Network connectivity problems\n"+ + " 4. Gas price estimation failed\n"+ + "You can disable this check with 'check_rpc_health_on_start = false' in config (seth.toml or ClientBuilder)", + err) } L.Info().Msg("RPC health check passed <---------------- !!!!! ----------------") @@ -441,7 +503,9 @@ func (m *Client) TransferETHFromKey(ctx context.Context, fromKeyNum int, to stri chainID, err := m.Client.ChainID(ctx) if err != nil { - return errors.Wrap(err, "failed to get network ID") + return fmt.Errorf("failed to get chain ID from RPC: %w\n"+ + "Ensure the RPC endpoint is accessible. Chain ID is required for EIP-155 transaction signing", + err) } var gasLimit int64 @@ -467,14 +531,21 @@ func (m *Client) TransferETHFromKey(ctx context.Context, fromKeyNum int, to stri L.Debug().Interface("TransferTx", rawTx).Send() signedTx, err := types.SignNewTx(m.PrivateKeys[fromKeyNum], types.NewEIP155Signer(chainID), rawTx) if err != nil { - return errors.Wrap(err, "failed to sign tx") + return fmt.Errorf("failed to sign transaction with key #%d (address: %s): %w\n"+ + "Verify the private key is valid and corresponds to the expected address", + fromKeyNum, m.Addresses[fromKeyNum].Hex(), err) } - ctx, sendCancel := context.WithTimeout(ctx, m.Cfg.Network.TxnTimeout.Duration()) - defer sendCancel() err = m.Client.SendTransaction(ctx, signedTx) if err != nil { - return errors.Wrap(err, "failed to send transaction") + return fmt.Errorf("failed to send transaction to network: %w\n"+ + "Common causes:\n"+ + " 1. RPC node rejected the transaction\n"+ + " 2. Gas price too low (try increasing gas_price or gas_fee_cap)\n"+ + " 3. Nonce conflict (transaction with same nonce already pending)\n"+ + " 4. Insufficient funds for gas (check account balance)\n"+ + " 5. Transaction timeout (check transaction_timeout in config)", + err) } l := L.With().Str("Transaction", signedTx.Hash().Hex()).Logger() l.Info(). @@ -852,7 +923,13 @@ This issue is caused by one of two things: opts, err := bind.NewKeyedTransactorWithChainID(m.PrivateKeys[keyNum], big.NewInt(m.ChainID)) if err != nil { - err = errors.Wrapf(err, "failed to create transactor for key %d", keyNum) + err = fmt.Errorf("failed to create transactor for key #%d (address: %s, chain ID: %d): %w\n"+ + "This usually indicates:\n"+ + " 1. Invalid private key format\n"+ + " 2. Chain ID mismatch\n"+ + " 3. Key not properly loaded\n"+ + "Verify the private key is valid and chain_id is correct in config", + keyNum, m.Addresses[keyNum].Hex(), m.ChainID, err) m.Errors = append(m.Errors, err) // can't return nil, otherwise RPC wrapper will panic and we might lose funds on testnets/mainnets, that's why // error is passed in Context here to avoid panic, whoever is using Seth should make sure that there is no error @@ -914,7 +991,7 @@ func (m *Client) CalculateGasEstimations(request GasEstimationRequest) GasEstima defer cancel() var disableEstimationsIfNeeded = func(err error) { - if strings.Contains(err.Error(), ZeroGasSuggestedErr) { + if errors.Is(err, ErrGasEstimation) { L.Warn().Msg("Received incorrect gas estimations. Disabling them and reverting to hardcoded values. Remember to update your config!") m.Cfg.Network.GasPriceEstimationEnabled = false } @@ -970,7 +1047,14 @@ func (m *Client) EstimateGasLimitForFundTransfer(from, to common.Address, amount }) if err != nil { L.Debug().Msgf("Failed to estimate gas for fund transfer due to: %s", err.Error()) - return 0, errors.Wrapf(err, "failed to estimate gas for fund transfer") + return 0, fmt.Errorf("failed to estimate gas for fund transfer from %s to %s (amount: %s wei): %w\n"+ + "Possible causes:\n"+ + " 1. Insufficient balance in sender account\n"+ + " 2. Invalid recipient address\n"+ + " 3. RPC node doesn't support gas estimation\n"+ + " 4. Network congestion or RPC issues\n"+ + "Try setting an explicit gas_limit in config if estimation continues to fail", + from.Hex(), to.Hex(), amount.String(), err) } return gasLimit, nil } @@ -1039,13 +1123,19 @@ func (m *Client) DeployContract(auth *bind.TransactOpts, name string, abi abi.AB if auth.Context != nil { if err, ok := auth.Context.Value(ContextErrorKey{}).(error); ok { - return DeploymentData{}, errors.Wrapf(err, "aborted contract deployment for %s, because context passed in transaction options had an error set", name) + return DeploymentData{}, fmt.Errorf("aborted contract deployment for '%s': context error: %w\n"+ + "This usually means there was an error creating transaction options. "+ + "Check the error above for details about what went wrong", + name, err) } } if m.Cfg.Hooks != nil && m.Cfg.Hooks.ContractDeployment.Pre != nil { if err := m.Cfg.Hooks.ContractDeployment.Pre(auth, name, abi, bytecode, params...); err != nil { - return DeploymentData{}, errors.Wrap(err, "pre-hook failed") + return DeploymentData{}, fmt.Errorf("contract deployment pre-hook failed for '%s': %w\n"+ + "The pre-deployment hook returned an error. "+ + "Check your hook implementation for issues", + name, err) } } else { L.Trace().Msg("No pre-contract deployment hook defined. Skipping") @@ -1069,7 +1159,10 @@ func (m *Client) DeployContract(auth *bind.TransactOpts, name string, abi abi.AB if m.Cfg.Hooks != nil && m.Cfg.Hooks.ContractDeployment.Post != nil { if err := m.Cfg.Hooks.ContractDeployment.Post(m, tx); err != nil { - return DeploymentData{}, errors.Wrap(err, "post-hook failed") + return DeploymentData{}, fmt.Errorf("contract deployment post-hook failed for transaction %s: %w\n"+ + "The post-deployment hook returned an error. "+ + "Check your hook implementation for issues", + tx.Hash().Hex(), err) } } else { L.Trace().Msg("No post-contract deployment hook defined. Skipping") @@ -1092,7 +1185,15 @@ func (m *Client) DeployContract(auth *bind.TransactOpts, name string, abi abi.AB cancel() if receipt.Status == 0 { - return errors.New("deployment transaction was reverted") + return fmt.Errorf("contract '%s' deployment transaction was reverted. "+ + "Transaction hash: %s\n"+ + "Common causes:\n"+ + " 1. Constructor parameters are incorrect\n"+ + " 2. Insufficient gas limit\n"+ + " 3. Constructor validation/require failed\n"+ + " 4. Contract bytecode is invalid\n"+ + "Check transaction trace with decode for the specific revert reason", + name, tx.Hash().Hex()) } } @@ -1192,7 +1293,14 @@ type DeploymentData struct { // name of ABI file (you can omit the .abi suffix). func (m *Client) DeployContractFromContractStore(auth *bind.TransactOpts, name string, params ...interface{}) (DeploymentData, error) { if m.ContractStore == nil { - return DeploymentData{}, errors.New("ABIStore is nil") + return DeploymentData{}, fmt.Errorf("contract store is nil. Cannot deploy contract from store.\n" + + "This usually means:\n" + + " 1. Seth client wasn't properly initialized\n" + + " 2. ABI directory path is incorrect in config\n" + + "Solutions:\n" + + " 1. Set 'abi_dir' and 'bin_dir' in seth.toml config file\n" + + " 2. Use ClientBuilder: builder.WithABIDir(path).WithBINDir(path)\n" + + " 3. Use DeployContract() method with explicit ABI/bytecode instead") } name = strings.TrimSuffix(name, ".abi") @@ -1200,12 +1308,18 @@ func (m *Client) DeployContractFromContractStore(auth *bind.TransactOpts, name s contractAbi, ok := m.ContractStore.ABIs[name+".abi"] if !ok { - return DeploymentData{}, errors.New("ABI not found") + return DeploymentData{}, fmt.Errorf("ABI for contract '%s' not found in contract store.\n"+ + "Total ABIs loaded: %d\n"+ + "Ensure the ABI file '%s.abi' exists in the directory specified by 'abi_dir' in your config", + name, len(m.ContractStore.ABIs), name) } bytecode, ok := m.ContractStore.BINs[name+".bin"] if !ok { - return DeploymentData{}, errors.New("BIN not found") + return DeploymentData{}, fmt.Errorf("bytecode (BIN) for contract '%s' not found in contract store.\n"+ + "Total BINs loaded: %d\n"+ + "Ensure the BIN file '%s.bin' exists in the directory specified by 'bin_dir' in your config", + name, len(m.ContractStore.BINs), name) } data, err := m.DeployContract(auth, name, contractAbi, bytecode, params...) @@ -1233,12 +1347,13 @@ func (t TransactionLog) GetData() []byte { return t.Data } -func (m *Client) decodeContractLogs(l zerolog.Logger, logs []types.Log, allABIs []*abi.ABI) ([]DecodedTransactionLog, error) { +func (m *Client) decodeContractLogs(l zerolog.Logger, logs []types.Log, allABIs []*abi.ABI) ([]DecodedTransactionLog, []EventDecodingError, error) { l.Trace(). Msg("Decoding events") sigMap := buildEventSignatureMap(allABIs) var eventsParsed []DecodedTransactionLog + var decodeErrors []EventDecodingError for _, lo := range logs { if len(lo.Topics) == 0 { l.Debug(). @@ -1272,6 +1387,7 @@ func (m *Client) decodeContractLogs(l zerolog.Logger, logs []types.Log, allABIs // Iterate over possible events with the same signature matched := false + var decodeAttempts []ABIDecodingError for _, evWithABI := range possibleEvents { evSpec := evWithABI.EventSpec contractABI := evWithABI.ContractABI @@ -1309,19 +1425,28 @@ func (m *Client) decodeContractLogs(l zerolog.Logger, logs []types.Log, allABIs } // Proceed to decode the event + // Find ABI name for this contract ABI + abiName := m.findABIName(contractABI) d := TransactionLog{lo.Topics, lo.Data} l.Trace(). Str("Name", evSpec.RawName). Str("Signature", evSpec.Sig). + Str("ABI", abiName). Msg("Unpacking event") eventsMap, topicsMap, err := decodeEventFromLog(l, *contractABI, *evSpec, d) if err != nil { - l.Error(). + l.Debug(). Err(err). Str("Event", evSpec.Name). - Msg("Failed to decode event; skipping") - continue // Skip this event instead of returning an error + Str("ABI", abiName). + Msg("Failed to decode event; trying next ABI") + decodeAttempts = append(decodeAttempts, ABIDecodingError{ + ABIName: abiName, + EventName: evSpec.Name, + Error: err.Error(), + }) + continue // Try next ABI instead of giving up } parsedEvent := decodedLogFromMaps(&DecodedTransactionLog{}, eventsMap, topicsMap) @@ -1343,12 +1468,36 @@ func (m *Client) decodeContractLogs(l zerolog.Logger, logs []types.Log, allABIs } if !matched { + // Record the decode failure + topics := make([]string, len(lo.Topics)) + for i, topic := range lo.Topics { + topics[i] = topic.Hex() + } + + decodeError := EventDecodingError{ + Signature: eventSig, + LogIndex: lo.Index, + Address: lo.Address.Hex(), + Topics: topics, + Errors: decodeAttempts, + } + decodeErrors = append(decodeErrors, decodeError) + + abiNames := make([]string, len(decodeAttempts)) + for i, attempt := range decodeAttempts { + abiNames[i] = attempt.ABIName + } + l.Warn(). Str("Signature", eventSig). - Msg("No matching event with valid indexed parameter count found for log") + Uint("LogIndex", lo.Index). + Str("Address", lo.Address.Hex()). + Strs("AttemptedABIs", abiNames). + Int("FailedAttempts", len(decodeAttempts)). + Msg("Failed to decode event log") } } - return eventsParsed, nil + return eventsParsed, decodeErrors, nil } type eventWithABI struct { @@ -1372,6 +1521,21 @@ func buildEventSignatureMap(allABIs []*abi.ABI) map[string][]*eventWithABI { return sigMap } +// findABIName finds the name of the ABI in the ContractStore, returns "unknown" if not found +func (m *Client) findABIName(targetABI *abi.ABI) string { + if m.ContractStore == nil { + return "unknown" + } + + for name, storedABI := range m.ContractStore.ABIs { + if reflect.DeepEqual(storedABI, *targetABI) { + return strings.TrimSuffix(name, ".abi") + } + } + + return "unknown" +} + // WaitUntilNoPendingTxForRootKey waits until there's no pending transaction for root key. If after timeout there are still pending transactions, it returns error. func (m *Client) WaitUntilNoPendingTxForRootKey(timeout time.Duration) error { return m.WaitUntilNoPendingTx(m.MustGetRootKeyAddress(), timeout) @@ -1396,7 +1560,14 @@ func (m *Client) WaitUntilNoPendingTx(address common.Address, timeout time.Durat for { select { case <-waitTimeout.C: - return fmt.Errorf("after '%s' address '%s' still had pending transactions", timeout, address) + return fmt.Errorf("timeout after %s: address %s still has pending transactions. "+ + "This means transactions haven't been mined within the timeout period.\n"+ + "Troubleshooting:\n"+ + " 1. Check if the network is processing transactions (check block explorer)\n"+ + " 2. Gas price might be too low (increase gas_price or gas_fee_cap)\n"+ + " 3. Network congestion (wait longer or increase timeout)\n"+ + " 4. Enable gas bumping: set gas_bump.retries > 0 in config", + timeout, address.Hex()) case <-ticker.C: nonceStatus, err := m.getNonceStatus(address) // if there is an error, we can't be sure if there are pending transactions or not, let's retry on next tick @@ -1419,9 +1590,19 @@ func (m *Client) WaitUntilNoPendingTx(address common.Address, timeout time.Durat func (m *Client) validatePrivateKeysKeyNum(keyNum int) error { if keyNum >= len(m.PrivateKeys) || keyNum < 0 { if len(m.PrivateKeys) == 0 { - return fmt.Errorf("no private keys were loaded, but keyNum %d was requested", keyNum) + return fmt.Errorf("no private keys loaded, but tried to use key #%d.\n"+ + "Load private keys by:\n"+ + " 1. Setting SETH_ROOT_PRIVATE_KEY environment variable\n"+ + " 2. Adding 'private_keys_secret' to your seth.toml network config\n"+ + " 3. Using WithPrivateKeys() with ClientBuilder", + keyNum) } - return fmt.Errorf("keyNum is out of range for known private keys. Expected %d to %d. Got: %d", 0, len(m.PrivateKeys)-1, keyNum) + return fmt.Errorf("keyNum %d is out of range. Available keys: 0-%d (total: %d keys loaded).\n"+ + "Common causes:\n"+ + " 1. Using keyNum from another test/context\n"+ + " 2. Not enough keys configured for parallel test execution\n"+ + " 3. Consider enabling ephemeral_addresses_number in your config for more keys", + keyNum, len(m.PrivateKeys)-1, len(m.PrivateKeys)) } return nil @@ -1430,9 +1611,14 @@ func (m *Client) validatePrivateKeysKeyNum(keyNum int) error { func (m *Client) validateAddressesKeyNum(keyNum int) error { if keyNum >= len(m.Addresses) || keyNum < 0 { if len(m.Addresses) == 0 { - return fmt.Errorf("no addresses were loaded, but keyNum %d was requested", keyNum) + return fmt.Errorf("no addresses loaded, but tried to use key #%d.\n"+ + "This should not happen if private keys were loaded correctly. "+ + "Please report this issue at https://github.com/smartcontractkit/chainlink-testing-framework/issues with the stack trace", + keyNum) } - return fmt.Errorf("keyNum is out of range for known addresses. Expected %d to %d. Got: %d", 0, len(m.Addresses)-1, keyNum) + return fmt.Errorf("keyNum %d is out of range. Available addresses: 0-%d (total: %d addresses loaded).\n"+ + "This indicates the keyNum is invalid for the current client configuration", + keyNum, len(m.Addresses)-1, len(m.Addresses)) } return nil diff --git a/seth/client_api_test.go b/seth/client_api_test.go index c27b54b72..3c5b77758 100644 --- a/seth/client_api_test.go +++ b/seth/client_api_test.go @@ -2,13 +2,13 @@ package seth_test import ( "context" + "errors" "math/big" "sync" "testing" "time" "github.com/ethereum/go-ethereum/core/types" - "github.com/pkg/errors" "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-testing-framework/seth" diff --git a/seth/client_builder.go b/seth/client_builder.go index c7ecfbf6e..d1e50faa3 100644 --- a/seth/client_builder.go +++ b/seth/client_builder.go @@ -1,11 +1,11 @@ package seth import ( + "errors" "fmt" "time" "github.com/ethereum/go-ethereum/ethclient/simulated" - "github.com/pkg/errors" ) const ( @@ -32,7 +32,7 @@ func NewClientBuilder() *ClientBuilder { DialTimeout: MustMakeDuration(DefaultDialTimeout), TransferGasFee: DefaultTransferGasFee, GasPriceEstimationEnabled: true, - GasPriceEstimationBlocks: 200, + GasPriceEstimationBlocks: 20, GasPriceEstimationTxPriority: Priority_Standard, GasPrice: DefaultGasPrice, GasFeeCap: DefaultGasFeeCap, diff --git a/seth/client_builder_test.go b/seth/client_builder_test.go index feda3282b..4560e0aa5 100644 --- a/seth/client_builder_test.go +++ b/seth/client_builder_test.go @@ -2,6 +2,7 @@ package seth_test import ( "crypto/ecdsa" + "errors" "math/big" "os" "strings" @@ -13,7 +14,6 @@ import ( "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" - "github.com/pkg/errors" "github.com/pelletier/go-toml/v2" @@ -339,7 +339,7 @@ func TestConfig_NoPrivateKeys_TxOpts(t *testing.T) { _ = client.NewTXOpts() require.Equal(t, 1, len(client.Errors), "expected 1 error") - require.Equal(t, "no private keys were loaded, but keyNum 0 was requested", client.Errors[0].Error(), "expected error message") + require.Contains(t, client.Errors[0].Error(), "no private keys loaded, but tried to use key #0.", "expected error message") } func TestConfig_NoPrivateKeys_Tracing(t *testing.T) { diff --git a/seth/client_contract_map_test.go b/seth/client_contract_map_test.go index 25f0311a9..dbc297774 100644 --- a/seth/client_contract_map_test.go +++ b/seth/client_contract_map_test.go @@ -142,7 +142,7 @@ func TestContractMapNewClientIsNotCreatedWhenCorruptedContractMapFileExists(t *t cfg.ContractMapFile = file.Name() newClient, err := seth.NewClientRaw(cfg, addresses, pks) require.Error(t, err, "succeeded in creation of new client") - require.Contains(t, err.Error(), seth.ErrReadContractMap, "expected error reading invalid toml") + require.Contains(t, err.Error(), "failed to load deployed contracts map from", "expected error reading invalid toml") require.Nil(t, newClient, "expected new client to be nil") } @@ -162,6 +162,6 @@ func TestContractMapNewClientIsNotCreatedWhenCorruptedContractMapFileExists_Inva cfg.ContractMapFile = file.Name() newClient, err := seth.NewClientRaw(cfg, addresses, pks) require.Error(t, err, "succeeded in creation of new client") - require.Contains(t, err.Error(), seth.ErrReadContractMap, "expected error reading invalid contract address") + require.Contains(t, err.Error(), "failed to load deployed contracts map from", "expected error reading invalid contract address") require.Nil(t, newClient, "expected new client to be nil") } diff --git a/seth/client_decode_test.go b/seth/client_decode_test.go index d55b287d5..7f740f5c0 100644 --- a/seth/client_decode_test.go +++ b/seth/client_decode_test.go @@ -39,7 +39,7 @@ func TestSmokeDebugReverts(t *testing.T) { name: "revert with a custom err", method: "alwaysRevertsCustomError", output: map[string]string{ - seth.GETH: "error type: CustomErr, error values: [12 21]", + seth.GETH: "transaction reverted with custom error: error type: CustomErr, error values: [12 21]", }, }, } @@ -57,7 +57,7 @@ func TestSmokeDebugReverts(t *testing.T) { expectedOutput = eo } } - require.Equal(t, expectedOutput, err.Error()) + require.Contains(t, err.Error(), expectedOutput, "expected error message to contain the reverted error type and values") }) } } diff --git a/seth/client_main_test.go b/seth/client_main_test.go index d2d09db44..262d9150c 100644 --- a/seth/client_main_test.go +++ b/seth/client_main_test.go @@ -2,6 +2,8 @@ package seth_test import ( "context" + "errors" + "fmt" "math/big" "os" "testing" @@ -11,7 +13,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/ethclient/simulated" - "github.com/pkg/errors" "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-testing-framework/seth" @@ -150,7 +151,7 @@ func NewDebugContractSetup() ( nm, err := seth.NewNonceManager(cfg, addrs, pkeys) if err != nil { - return nil, nil, common.Address{}, common.Address{}, nil, errors.Wrap(err, seth.ErrCreateNonceManager) + return nil, nil, common.Address{}, common.Address{}, nil, fmt.Errorf("failed to create nonce manager: %w", err) } c, err := seth.NewClientRaw(cfg, addrs, pkeys, seth.WithContractStore(cs), seth.WithTracer(tracer), seth.WithNonceManager(nm)) diff --git a/seth/client_test.go b/seth/client_test.go index 37893c594..fead8d5ae 100644 --- a/seth/client_test.go +++ b/seth/client_test.go @@ -1,6 +1,7 @@ package seth_test import ( + "strings" "testing" "github.com/ethereum/go-ethereum/common" @@ -40,7 +41,9 @@ func TestRPCHealthCheckEnabled_Node_Unhealthy(t *testing.T) { _, err = seth.NewClientWithConfig(cfg) require.Error(t, err, "expected error when connecting to unhealthy node") - require.Contains(t, err.Error(), seth.ErrRpcHealthCheckFailed, "expected error message when connecting to dead node") + // Geth returns "insufficient funds for gas" error + // Anvil returns "insufficient funds for gas" error + require.Contains(t, strings.ToLower(err.Error()), strings.ToLower("RPC health check failed: failed to send transaction to network: Insufficient funds for gas"), "expected error message when connecting to dead node") } func TestRPCHealthCheckDisabled_Node_Unhealthy(t *testing.T) { diff --git a/seth/client_trace_test.go b/seth/client_trace_test.go index fbb44d6e8..de122ee2c 100644 --- a/seth/client_trace_test.go +++ b/seth/client_trace_test.go @@ -1229,7 +1229,7 @@ func TestTraceContractAll(t *testing.T) { require.NoError(t, txErr, "transaction sending should not fail") _, decodeErr := c.Decode(revertedTx, txErr) require.Error(t, decodeErr, "transaction should have reverted") - require.Equal(t, "error type: CustomErr, error values: [12 21]", decodeErr.Error(), "expected error message to contain the reverted error type and values") + require.Equal(t, "transaction reverted with custom error: error type: CustomErr, error values: [12 21]", decodeErr.Error(), "expected error message to contain the reverted error type and values") okTx, txErr := TestEnv.DebugContract.AddCounter(c.NewTXOpts(), big.NewInt(1), big.NewInt(2)) require.NoError(t, txErr, "transaction should not have reverted") @@ -1282,7 +1282,7 @@ func TestTraceContractOnlyReverted(t *testing.T) { require.NoError(t, txErr, "transaction sending should not fail") _, decodeErr := c.Decode(revertedTx, txErr) require.Error(t, decodeErr, "transaction should have reverted") - require.Equal(t, "error type: CustomErr, error values: [12 21]", decodeErr.Error(), "expected error message to contain the reverted error type and values") + require.Equal(t, "transaction reverted with custom error: error type: CustomErr, error values: [12 21]", decodeErr.Error(), "expected error message to contain the reverted error type and values") okTx, txErr := TestEnv.DebugContract.AddCounter(c.NewTXOpts(), big.NewInt(1), big.NewInt(2)) require.NoError(t, txErr, "transaction should not have reverted") @@ -1320,7 +1320,7 @@ func TestTraceContractNone(t *testing.T) { require.NoError(t, txErr, "transaction sending should not fail") _, decodeErr := c.Decode(revertedTx, txErr) require.Error(t, decodeErr, "transaction should have reverted") - require.Equal(t, "error type: CustomErr, error values: [12 21]", decodeErr.Error(), "expected error message to contain the reverted error type and values") + require.Equal(t, "transaction reverted with custom error: error type: CustomErr, error values: [12 21]", decodeErr.Error(), "expected error message to contain the reverted error type and values") okTx, txErr := TestEnv.DebugContract.AddCounter(c.NewTXOpts(), big.NewInt(1), big.NewInt(2)) require.NoError(t, txErr, "transaction should not have reverted") @@ -1341,7 +1341,7 @@ func TestTraceContractRevertedErrNoValues(t *testing.T) { require.NoError(t, txErr, "transaction should have reverted") _, decodeErr := c.Decode(tx, txErr) require.Error(t, decodeErr, "transaction should have reverted") - require.Equal(t, "error type: CustomErrNoValues, error values: []", decodeErr.Error(), "expected error message to contain the reverted error type and values") + require.Equal(t, "transaction reverted with custom error: error type: CustomErrNoValues, error values: []", decodeErr.Error(), "expected error message to contain the reverted error type and values") require.Equal(t, 1, len(c.Tracer.GetAllDecodedCalls()), "expected 1 decoded transacton") expectedCall := &seth.DecodedCall{ @@ -1373,7 +1373,7 @@ func TestTraceCallRevertFunctionInTheContract(t *testing.T) { require.NoError(t, txErr, "transaction should have reverted") _, decodeErr := c.Decode(tx, txErr) require.Error(t, decodeErr, "transaction should have reverted") - require.Equal(t, "error type: CustomErr, error values: [12 21]", decodeErr.Error(), "expected error message to contain the reverted error type and values") + require.Equal(t, "transaction reverted with custom error: error type: CustomErr, error values: [12 21]", decodeErr.Error(), "expected error message to contain the reverted error type and values") require.Equal(t, 1, len(c.Tracer.GetAllDecodedCalls()), "expected 1 decoded transacton") expectedCall := &seth.DecodedCall{ @@ -1407,7 +1407,7 @@ func TestTraceCallRevertFunctionInSubContract(t *testing.T) { require.NoError(t, txErr, "transaction should have reverted") _, decodeErr := c.Decode(tx, txErr) require.Error(t, decodeErr, "transaction should have reverted") - require.Equal(t, "error type: CustomErr, error values: [1001 2]", decodeErr.Error(), "expected error message to contain the reverted error type and values") + require.Equal(t, "transaction reverted with custom error: error type: CustomErr, error values: [1001 2]", decodeErr.Error(), "expected error message to contain the reverted error type and values") require.Equal(t, 1, len(c.Tracer.GetAllDecodedCalls()), "expected 1 decoded transaction") expectedCall := &seth.DecodedCall{ @@ -1446,7 +1446,7 @@ func TestTraceCallRevertInCallback(t *testing.T) { require.NoError(t, txErr, "transaction should have reverted") _, decodeErr := c.Decode(tx, txErr) require.Error(t, decodeErr, "transaction should have reverted") - require.Equal(t, "error type: CustomErr, error values: [99 101]", decodeErr.Error(), "expected error message to contain the reverted error type and values") + require.Equal(t, "transaction reverted with custom error: error type: CustomErr, error values: [99 101]", decodeErr.Error(), "expected error message to contain the reverted error type and values") } func TestTraceOldPragmaNoRevertReason(t *testing.T) { @@ -1493,7 +1493,7 @@ func TestTraceeRevertReasonNonRootSender(t *testing.T) { require.NoError(t, txErr, "transaction should have reverted") _, decodeErr := c.Decode(tx, txErr) require.Error(t, decodeErr, "transaction should have reverted") - require.Equal(t, "error type: CustomErr, error values: [1001 2]", decodeErr.Error(), "expected error message to contain the reverted error type and values") + require.Equal(t, "transaction reverted with custom error: error type: CustomErr, error values: [1001 2]", decodeErr.Error(), "expected error message to contain the reverted error type and values") require.Equal(t, 1, len(c.Tracer.GetAllDecodedCalls()), "expected 1 decoded transaction") expectedCall := &seth.DecodedCall{ diff --git a/seth/cmd/seth.go b/seth/cmd/seth.go index e3bbb0e6a..1778f8855 100644 --- a/seth/cmd/seth.go +++ b/seth/cmd/seth.go @@ -2,6 +2,7 @@ package seth import ( "context" + "errors" "fmt" "math/big" "os" @@ -12,7 +13,6 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/ethclient" "github.com/pelletier/go-toml/v2" - "github.com/pkg/errors" "github.com/urfave/cli/v2" "github.com/smartcontractkit/chainlink-testing-framework/seth" @@ -205,11 +205,11 @@ func RunCLI(args []string) error { var cfg *seth.Config d, err := os.ReadFile(cfgPath) if err != nil { - return errors.Wrap(err, seth.ErrReadSethConfig) + return fmt.Errorf("%s: %w", seth.ErrReadSethConfig, err) } err = toml.Unmarshal(d, &cfg) if err != nil { - return errors.Wrap(err, seth.ErrUnmarshalSethConfig) + return fmt.Errorf("%s: %w", seth.ErrUnmarshalSethConfig, err) } absPath, err := filepath.Abs(cfgPath) if err != nil { @@ -272,7 +272,7 @@ func RunCLI(args []string) error { if cfg.Network.Name == seth.DefaultNetworkName { chainId, err := client.ChainID(context.Background()) if err != nil { - return errors.Wrap(err, "failed to get chain ID") + return fmt.Errorf("failed to get chain ID: %w", err) } cfg.Network.ChainID = chainId.Uint64() } @@ -298,7 +298,7 @@ func RunCLI(args []string) error { tx, _, err := client.Client.TransactionByHash(ctx, common.HexToHash(txHash)) cancel() if err != nil { - return errors.Wrapf(err, "failed to get transaction %s", txHash) + return fmt.Errorf("failed to get transaction %s: %w", txHash, err) } _, err = client.Decode(tx, nil) diff --git a/seth/config.go b/seth/config.go index b2403d7d0..2859368ab 100644 --- a/seth/config.go +++ b/seth/config.go @@ -13,7 +13,6 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient/simulated" "github.com/pelletier/go-toml/v2" - "github.com/pkg/errors" ) const ( @@ -131,16 +130,29 @@ func DefaultClient(rpcUrl string, privateKeys []string) (*Client, error) { func ReadConfig() (*Config, error) { cfgPath := os.Getenv(CONFIG_FILE_ENV_VAR) if cfgPath == "" { - return nil, errors.New(ErrEmptyConfigPath) + return nil, fmt.Errorf("SETH_CONFIG_PATH environment variable is not set. " + + "Set it to the absolute path of your seth.toml configuration file.\n" + + "Example: export SETH_CONFIG_PATH=/path/to/your/seth.toml") } var cfg *Config d, err := os.ReadFile(cfgPath) if err != nil { - return nil, errors.Wrap(err, ErrReadSethConfig) + return nil, fmt.Errorf("failed to read Seth config file at '%s': %w\n"+ + "Ensure:\n"+ + " 1. The file exists at the specified path (set via SETH_CONFIG_PATH)\n"+ + " 2. You have read permissions for the file", + cfgPath, err) } err = toml.Unmarshal(d, &cfg) if err != nil { - return nil, errors.Wrap(err, ErrUnmarshalSethConfig) + return nil, fmt.Errorf("failed to parse Seth TOML config from '%s': %w\n"+ + "Ensure the file contains valid TOML syntax. "+ + "Common issues:\n"+ + " 1. Missing quotes around string values\n"+ + " 2. Invalid array or table syntax\n"+ + " 3. Duplicate keys\n"+ + "See example config: https://github.com/smartcontractkit/chainlink-testing-framework/blob/main/seth/seth.toml", + cfgPath, err) } absPath, err := filepath.Abs(cfgPath) if err != nil { @@ -162,7 +174,15 @@ func ReadConfig() (*Config, error) { url := os.Getenv(URL_ENV_VAR) if url == "" { - return nil, fmt.Errorf("network not selected, set %s=... or %s=..., check TOML config for available networks", NETWORK_ENV_VAR, URL_ENV_VAR) + availableNetworks := make([]string, 0, len(cfg.Networks)) + for _, n := range cfg.Networks { + availableNetworks = append(availableNetworks, n.Name) + } + return nil, fmt.Errorf("network not selected. Set either:\n"+ + " - SETH_NETWORK to one of: %s\n"+ + " - SETH_URL to a custom RPC endpoint\n"+ + "Check your TOML config at %s for network details", + strings.Join(availableNetworks, ", "), cfgPath) } //look for default network @@ -182,13 +202,20 @@ func ReadConfig() (*Config, error) { } if cfg.Network == nil { - return nil, fmt.Errorf("default network not defined in the TOML file") + return nil, fmt.Errorf("default network not defined in the TOML file at %s. "+ + "Add a network with name='%s' or specify a network using SETH_NETWORK environment variable", + cfgPath, DefaultNetworkName) } } rootPrivateKey := os.Getenv(ROOT_PRIVATE_KEY_ENV_VAR) if rootPrivateKey == "" { - return nil, errors.Errorf(ErrEmptyRootPrivateKey, ROOT_PRIVATE_KEY_ENV_VAR) + return nil, fmt.Errorf("no root private key was set. "+ + "You can provide the root private key via:\n"+ + " 1. %s environment variable (without 0x prefix)\n"+ + " 2. 'private_keys_secret' array in seth.toml [[networks]] section\n"+ + "WARNING: Never commit private keys to source control. We recommend to use the environment variable.", + ROOT_PRIVATE_KEY_ENV_VAR) } cfg.Network.PrivateKeys = append(cfg.Network.PrivateKeys, rootPrivateKey) if cfg.Network.DialTimeout == nil { @@ -205,7 +232,13 @@ func ReadConfig() (*Config, error) { // If any configuration is invalid, it returns an error. func (c *Config) Validate() error { if c.Network == nil { - return errors.New(ErrNetworkIsNil) + return fmt.Errorf("network configuration is nil. " + + "This usually means the network wasn't selected or configured properly.\n" + + "Solutions:\n" + + " 1. Set SETH_NETWORK environment variable to match a network name in seth.toml (e.g., SETH_NETWORK=sepolia)\n" + + " 2. Ensure your seth.toml has a [[networks]] section with 'name' field matching SETH_NETWORK\n" + + " 3. Use ClientBuilder with WithNetwork() to configure the network programmatically\n" + + "See documentation for configuration examples") } if c.Network.GasPriceEstimationEnabled { @@ -225,12 +258,20 @@ func (c *Config) Validate() error { case Priority_Slow: case Priority_Auto: default: - return errors.New("when automating gas estimation is enabled priority must be auto, fast, standard or slow. fix it or disable gas estimation") + return fmt.Errorf("invalid gas estimation priority '%s'. "+ + "Must be one of: 'auto', 'fast', 'standard' or 'slow'. "+ + "Set 'gas_price_estimation_tx_priority' in your seth.toml config. "+ + "To disable gas estimation, set 'gas_price_estimation_enabled = false'", + c.Network.GasPriceEstimationTxPriority) } if c.GasBump != nil && c.GasBump.Retries > 0 && c.Network.GasPriceEstimationTxPriority == Priority_Auto { - return errors.New("gas bumping is not compatible with auto priority gas estimation") + return fmt.Errorf("configuration conflict: gas bumping (retries=%d) is not compatible with auto priority gas estimation. "+ + "Either:\n"+ + " 1. Set gas_price_estimation_tx_priority to 'fast', 'standard', or 'slow'\n"+ + " 2. Set gas_bump.retries = 0 to disable gas bumping", + c.GasBump.Retries) } } @@ -254,7 +295,10 @@ func (c *Config) Validate() error { case TracingLevel_Reverted: case TracingLevel_All: default: - return errors.New("tracing level must be one of: NONE, REVERTED, ALL") + return fmt.Errorf("invalid tracing level '%s'. Must be one of: 'NONE', 'REVERTED', 'ALL'. "+ + "Set 'tracing_level' in your seth.toml config or via WithTracing() option, if using ClientBuilder().\n"+ + "Recommended: 'REVERTED' for debugging failed transactions, 'NONE' to disable tracing completely", + c.TracingLevel) } for _, output := range c.TraceOutputs { @@ -263,7 +307,10 @@ func (c *Config) Validate() error { case TraceOutput_JSON: case TraceOutput_DOT: default: - return errors.New("trace output must be one of: console, json, dot") + return fmt.Errorf("invalid trace output '%s'. Must be one of: 'console', 'json', 'dot'. "+ + "Set 'trace_outputs' in your seth.toml config or via WithTracing() option, if using ClientBuilder()"+ + "You can specify multiple outputs as an array", + output) } } @@ -276,11 +323,18 @@ func (c *Config) Validate() error { } if c.ethclient == nil && len(c.Network.URLs) == 0 { - return errors.New("at least one url should be present in config in 'secret_urls = []'") + return fmt.Errorf("no RPC URLs configured. " + + "You can provide RPC URLs via:\n" + + " 1. 'urls_secret' field in seth.toml: urls_secret = [\"http://your-rpc-url\"]\n" + + " 2. WithRPCURLs() when using ClientBuilder\n" + + " 3. WithEthClient() to provide a pre-configured ethclient instance") } if c.ethclient != nil && len(c.Network.URLs) > 0 { - return errors.New(EthClientAndUrlsSet) + return fmt.Errorf("configuration conflict: both ethclient instance and RPC URLs are set. " + + "You cannot set both. Either:\n" + + " 1. Use the provided ethclient (remove 'urls_secret' from config)\n" + + " 2. Use RPC URLs from config (don't provide ethclient)") } return nil diff --git a/seth/config_test.go b/seth/config_test.go index 6d550ba2a..095cf86ed 100644 --- a/seth/config_test.go +++ b/seth/config_test.go @@ -131,7 +131,7 @@ func TestConfig_ReadOnly_WithPk(t *testing.T) { _, err := seth.NewClientRaw(&cfg, addrs, nil) require.Error(t, err, "succeeded in creating client") - require.Equal(t, seth.ErrReadOnlyWithPrivateKeys, err.Error(), "expected different error message") + require.Contains(t, err.Error(), "configuration conflict: read-only mode is enabled, but private keys were provided.", "expected different error message") privateKey, err := crypto.HexToECDSA("ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80") require.NoError(t, err, "failed to parse private key") @@ -139,11 +139,11 @@ func TestConfig_ReadOnly_WithPk(t *testing.T) { pks := []*ecdsa.PrivateKey{privateKey} _, err = seth.NewClientRaw(&cfg, nil, pks) require.Error(t, err, "succeeded in creating client") - require.Equal(t, seth.ErrReadOnlyWithPrivateKeys, err.Error(), "expected different error message") + require.Contains(t, err.Error(), "configuration conflict: read-only mode is enabled, but private keys were provided.", "expected different error message") _, err = seth.NewClientRaw(&cfg, addrs, pks) require.Error(t, err, "succeeded in creating client") - require.Equal(t, seth.ErrReadOnlyWithPrivateKeys, err.Error(), "expected different error message") + require.Contains(t, err.Error(), "configuration conflict: read-only mode is enabled, but private keys were provided.", "expected different error message") } func TestConfig_ReadOnly_GasBumping(t *testing.T) { @@ -161,7 +161,7 @@ func TestConfig_ReadOnly_GasBumping(t *testing.T) { _, err := seth.NewClientRaw(&cfg, nil, nil) require.Error(t, err, "succeeded in creating client") - require.Equal(t, seth.ErrReadOnlyGasBumping, err.Error(), "expected different error message") + require.Contains(t, err.Error(), "gas bumping is not supported in read-only mode because it requires sending replacement transactions.", "expected different error message") } func TestConfig_ReadOnly_RpcHealth(t *testing.T) { @@ -177,7 +177,7 @@ func TestConfig_ReadOnly_RpcHealth(t *testing.T) { _, err := seth.NewClientRaw(&cfg, nil, nil) require.Error(t, err, "succeeded in creating client") - require.Equal(t, seth.ErrReadOnlyRpcHealth, err.Error(), "expected different error message") + require.Contains(t, err.Error(), "RPC health check is not supported in read-only mode", "expected different error message") } func TestConfig_ReadOnly_PendingNonce(t *testing.T) { @@ -193,7 +193,7 @@ func TestConfig_ReadOnly_PendingNonce(t *testing.T) { _, err := seth.NewClientRaw(&cfg, nil, nil) require.Error(t, err, "succeeded in creating client") - require.Equal(t, seth.ErrReadOnlyPendingNonce, err.Error(), "expected different error message") + require.Contains(t, err.Error(), "pending nonce protection is not supported in read-only mode because it requires transaction monitoring.", "expected different error message") } func TestConfig_ReadOnly_EphemeralKeys(t *testing.T) { @@ -210,5 +210,5 @@ func TestConfig_ReadOnly_EphemeralKeys(t *testing.T) { _, err := seth.NewClientRaw(&cfg, nil, nil) require.Error(t, err, "succeeded in creating client") - require.Equal(t, seth.ErrNoPksEphemeralMode, err.Error(), "expected different error message") + require.Contains(t, err.Error(), "ephemeral mode requires exactly one root private key to fund ephemeral addresses, but no keys were loaded.", "expected different error message") } diff --git a/seth/contract_store.go b/seth/contract_store.go index 5d8424f0e..0b7f4f5d5 100644 --- a/seth/contract_store.go +++ b/seth/contract_store.go @@ -13,7 +13,6 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" - "github.com/pkg/errors" ) const ( @@ -111,7 +110,12 @@ func NewContractStore(abiPath, binPath string, gethWrappersPaths []string) (*Con err = cs.loadGethWrappers(gethWrappersPaths) if err != nil { - return nil, errors.Wrapf(err, "failed to load geth wrappers from %v", gethWrappersPaths) + return nil, fmt.Errorf("failed to load geth wrappers from %v: %w\n"+ + "Ensure:\n"+ + " 1. The paths point to valid Go files with geth-generated contract wrappers\n"+ + " 2. Files contain properly formatted ABI JSON in comments\n"+ + " 3. The wrapper files were generated with abigen tool", + gethWrappersPaths, err) } return cs, nil @@ -129,18 +133,32 @@ func (c *ContractStore) loadABIs(abiPath string) error { L.Debug().Str("File", f.Name()).Msg("ABI file loaded") ff, err := os.Open(filepath.Join(abiPath, f.Name())) if err != nil { - return errors.Wrap(err, ErrOpenABIFile) + return fmt.Errorf("failed to open ABI file '%s': %w\n"+ + "Ensure the file exists and has proper read permissions", + filepath.Join(abiPath, f.Name()), err) } a, err := abi.JSON(ff) if err != nil { - return errors.Wrap(err, ErrParseABI) + return fmt.Errorf("failed to parse ABI file '%s': %w\n"+ + "Ensure the file contains valid JSON ABI format. "+ + "ABI files should be generated from contract compilation (e.g., solc, hardhat, foundry)", + f.Name(), err) } c.ABIs[f.Name()] = a foundABI = true } } if !foundABI { - return fmt.Errorf("no ABI files found in '%s'. Fix the path or comment out 'abi_dir' setting", abiPath) + absPath, _ := filepath.Abs(abiPath) + return fmt.Errorf("no ABI files (*.abi) found in directory '%s'.\n"+ + "ABI files are JSON files describing contract interfaces.\n"+ + "Solutions:\n"+ + " 1. Verify the path is correct: %s\n"+ + " 2. Ensure .abi files exist in this directory\n"+ + " 3. Check directory permissions (must be readable)\n"+ + " 4. If deploying contracts without ABIs, remove 'abi_dir' from config\n"+ + " 5. Generate ABIs from Solidity: solc --abi YourContract.sol -o abi_dir/", + abiPath, absPath) } } @@ -159,14 +177,25 @@ func (c *ContractStore) loadBINs(binPath string) error { L.Debug().Str("File", f.Name()).Msg("BIN file loaded") bin, err := os.ReadFile(filepath.Join(binPath, f.Name())) if err != nil { - return errors.Wrap(err, ErrOpenBINFile) + return fmt.Errorf("failed to open BIN file '%s': %w\n"+ + "Ensure the file exists and has proper read permissions", + filepath.Join(binPath, f.Name()), err) } c.BINs[f.Name()] = common.FromHex(string(bin)) foundBIN = true } } if !foundBIN { - return fmt.Errorf("no BIN files found in '%s'. Fix the path or comment out 'bin_dir' setting", binPath) + absPath, _ := filepath.Abs(binPath) + return fmt.Errorf("no BIN files (*.bin) found in directory '%s'.\n"+ + "BIN files contain compiled contract bytecode needed for deployment.\n"+ + "Solutions:\n"+ + " 1. Verify the path is correct: %s\n"+ + " 2. Ensure .bin files exist (should contain hex-encoded bytecode)\n"+ + " 3. Check directory permissions (must be readable)\n"+ + " 4. If deploying contracts without BIN files, remove 'bin_dir' from config\n"+ + " 5. Generate BINs from Solidity: solc --bin YourContract.sol -o bin_dir/", + binPath, absPath) } } @@ -207,7 +236,15 @@ func (c *ContractStore) loadGethWrappers(gethWrappersPaths []string) error { } if len(gethWrappersPaths) > 0 && !foundWrappers { - return fmt.Errorf("no geth wrappers found in '%v'. Fix the path or comment out 'geth_wrappers_dirs' setting", gethWrappersPaths) + return fmt.Errorf("no geth wrapper files found in directories: %v\n"+ + "Geth wrappers are Go files generated by abigen containing contract ABIs.\n"+ + "Solutions:\n"+ + " 1. Verify all paths exist and are readable\n"+ + " 2. Generate wrappers using abigen:\n"+ + " abigen --abi contract.abi --bin contract.bin --pkg wrappers --out contract_wrapper.go\n"+ + " 3. Ensure wrapper files contain ABI metadata (check for 'ABI' variable)\n"+ + " 4. If not using geth wrappers, remove 'geth_wrappers_dirs' from config (seth.toml or ClientBuilder)", + gethWrappersPaths) } return nil @@ -253,12 +290,18 @@ TOP_LOOP: // this cleans up all escape and similar characters that might interfere with the JSON unmarshalling var rawAbi interface{} if err := json.Unmarshal([]byte(abiContent), &rawAbi); err != nil { - return "", nil, errors.Wrap(err, "failed to unmarshal ABI content") + return "", nil, fmt.Errorf("failed to unmarshal ABI content from '%s': %w\n"+ + "The ABI JSON in the wrapper file is malformed. "+ + "Ensure the file was generated correctly with abigen", + filePath, err) } parsedAbi, err := abi.JSON(strings.NewReader(fmt.Sprint(rawAbi))) if err != nil { - return "", nil, errors.Wrap(err, "failed to parse ABI content") + return "", nil, fmt.Errorf("failed to parse ABI content from '%s': %w\n"+ + "The ABI structure is invalid. "+ + "Regenerate the wrapper file with abigen", + filePath, err) } return contractName, &parsedAbi, nil diff --git a/seth/contract_store_test.go b/seth/contract_store_test.go index f9436e971..071c15396 100644 --- a/seth/contract_store_test.go +++ b/seth/contract_store_test.go @@ -1,9 +1,9 @@ package seth_test import ( + "errors" "testing" - "github.com/pkg/errors" "github.com/stretchr/testify/require" "github.com/smartcontractkit/chainlink-testing-framework/seth" @@ -63,18 +63,18 @@ func TestSmokeContractABIStore(t *testing.T) { { name: "empty ABI dir", abiPath: "./contracts/emptyContractDir", - err: "no ABI files found in './contracts/emptyContractDir'. Fix the path or comment out 'abi_dir' setting", + err: "no ABI files (*.abi) found in directory './contracts/emptyContractDir'.", }, { name: "empty gethwrappers dir", gethWrappersPaths: []string{"./contracts/emptyContractDir"}, - err: "failed to load geth wrappers from [./contracts/emptyContractDir]: no geth wrappers found in '[./contracts/emptyContractDir]'. Fix the path or comment out 'geth_wrappers_dirs' setting", + err: "failed to load geth wrappers from [./contracts/emptyContractDir]: no geth wrapper files found in directories: [./contracts/emptyContractDir]", }, { name: "empty ABI and gethwrappers dir", abiPath: "./contracts/emptyContractDir", gethWrappersPaths: []string{"./contracts/emptyContractDir"}, - err: "no ABI files found in './contracts/emptyContractDir'. Fix the path or comment out 'abi_dir' setting", + err: "no ABI files (*.abi) found in directory './contracts/emptyContractDir'.", }, { name: "no MetaData in one of gethwrappers", @@ -84,7 +84,7 @@ func TestSmokeContractABIStore(t *testing.T) { { name: "empty MetaData in one of gethwrappers", gethWrappersPaths: []string{"./contracts/emptyMetaDataContractDir"}, - err: "failed to load geth wrappers from [./contracts/emptyMetaDataContractDir]: failed to parse ABI content: EOF", + err: "failed to load geth wrappers from [./contracts/emptyMetaDataContractDir]: failed to parse ABI content from 'contracts/emptyMetaDataContractDir/NetworkDebugContract_Broken.go': EOF", }, { name: "gethwrappers dir mixes regular go files and gethwrappers", @@ -94,12 +94,12 @@ func TestSmokeContractABIStore(t *testing.T) { { name: "invalid ABI inside ABI dir", abiPath: "./contracts/invalidContractDir", - err: "failed to parse ABI file: invalid character ':' after array element", + err: "failed to parse ABI file 'NetworkDebugContract.abi': invalid character ':' after array element", }, { name: "invalid ABI in gethwrappers inside dir", gethWrappersPaths: []string{"./contracts/invalidContractDir"}, - err: "failed to load geth wrappers from [./contracts/invalidContractDir]: failed to parse ABI content: invalid character 'i' looking for beginning of value", + err: "failed to load geth wrappers from [./contracts/invalidContractDir]: failed to parse ABI content from 'contracts/invalidContractDir/NetworkDebugContract_Broken.go': invalid character 'i' looking for beginning of value", }, } @@ -114,7 +114,7 @@ func TestSmokeContractABIStore(t *testing.T) { require.Equal(t, make(map[string][]uint8), cs.BINs) err = errors.New("") } - require.Equal(t, tc.err, err.Error()) + require.Contains(t, err.Error(), tc.err, "error should match") }) } } @@ -152,7 +152,7 @@ func TestSmokeContractBINStore(t *testing.T) { name: "empty BIN dir", abiPath: "./contracts/abi", binPath: "./contracts/emptyContractDir", - err: "no BIN files found in './contracts/emptyContractDir'. Fix the path or comment out 'bin_dir' setting", + err: "no BIN files (*.bin) found in directory './contracts/emptyContractDir'.", }, } @@ -171,7 +171,7 @@ func TestSmokeContractBINStore(t *testing.T) { } else { require.Nil(t, cs, "ContractStore should be nil") } - require.Equal(t, tc.err, err.Error(), "error should match") + require.Contains(t, err.Error(), tc.err, "error should match") }) } } diff --git a/seth/decode.go b/seth/decode.go index 6daa65c7e..ba50f9dd6 100644 --- a/seth/decode.go +++ b/seth/decode.go @@ -4,7 +4,7 @@ import ( "bytes" "context" "encoding/hex" - verr "errors" + "errors" "fmt" "math/big" "path/filepath" @@ -17,32 +17,43 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/rpc" - "github.com/pkg/errors" "github.com/rs/zerolog" ) -const ( - ErrDecodeInput = "failed to decode transaction input" - ErrDecodeOutput = "failed to decode transaction output" - ErrDecodeLog = "failed to decode log" - ErrDecodedLogNonIndexed = "failed to decode non-indexed log data" - ErrDecodeILogIndexed = "failed to decode indexed log data" - ErrTooShortTxData = "tx data is less than 4 bytes, can't decode" - ErrRPCJSONCastError = "failed to cast CallMsg error as rpc.DataError" - ErrUnableToDecode = "unable to decode revert reason" +var ( + ErrNoABIMethod = errors.New("no ABI method found") +) +const ( WarnNoContractStore = "ContractStore is nil, use seth.NewContractStore(...) to decode transactions" ) // DecodedTransaction decoded transaction type DecodedTransaction struct { CommonData - Index uint `json:"index"` - Hash string `json:"hash,omitempty"` - Protected bool `json:"protected,omitempty"` - Transaction *types.Transaction `json:"transaction,omitempty"` - Receipt *types.Receipt `json:"receipt,omitempty"` - Events []DecodedTransactionLog `json:"events,omitempty"` + Index uint `json:"index"` + Hash string `json:"hash,omitempty"` + Protected bool `json:"protected,omitempty"` + Transaction *types.Transaction `json:"transaction,omitempty"` + Receipt *types.Receipt `json:"receipt,omitempty"` + Events []DecodedTransactionLog `json:"events,omitempty"` + EventDecodingErrors []EventDecodingError `json:"event_decoding_errors,omitempty"` +} + +// EventDecodingError represents a failed event decode attempt +type EventDecodingError struct { + Signature string `json:"signature"` + LogIndex uint `json:"log_index"` + Address string `json:"address"` + Topics []string `json:"topics,omitempty"` + Errors []ABIDecodingError `json:"errors,omitempty"` +} + +// ABIDecodingError represents a single ABI decode attempt failure +type ABIDecodingError struct { + ABIName string `json:"abi_name"` + EventName string `json:"event_name"` + Error string `json:"error"` } type CommonData struct { @@ -103,7 +114,7 @@ func getDefaultDecodedCall() *DecodedCall { // Last, but not least, if gas bumps are enabled, we will try to bump gas on transaction mining timeout and resubmit it with higher gas. func (m *Client) Decode(tx *types.Transaction, txErr error) (*DecodedTransaction, error) { if len(m.Errors) > 0 { - return nil, verr.Join(m.Errors...) + return nil, errors.Join(m.Errors...) } if decodedErr := m.DecodeSendErr(txErr); decodedErr != nil { @@ -125,7 +136,7 @@ func (m *Client) DecodeSendErr(txErr error) error { reason, decodingErr := m.DecodeCustomABIErr(txErr) if decodingErr == nil && reason != "" { - return errors.Wrap(txErr, reason) + return fmt.Errorf("%s: %w", reason, txErr) } L.Trace(). @@ -180,11 +191,6 @@ func (m *Client) DecodeTx(tx *types.Transaction) (*DecodedTransaction, error) { Msg("No post-decode hook found. Skipping") } - if decodeErr != nil && errors.Is(decodeErr, errors.New(ErrNoABIMethod)) { - m.handleTxDecodingError(l, *decoded, decodeErr) - return decoded, revertErr - } - if m.Cfg.TracingLevel == TracingLevel_None { m.handleDisabledTracing(l, *decoded) return decoded, revertErr @@ -252,32 +258,6 @@ func (m *Client) waitUntilMined(l zerolog.Logger, tx *types.Transaction) (*types return tx, receipt, nil } -func (m *Client) handleTxDecodingError(l zerolog.Logger, decoded DecodedTransaction, decodeErr error) { - tx := decoded.Transaction - - if m.Cfg.hasOutput(TraceOutput_JSON) { - l.Trace(). - Err(decodeErr). - Msg("Failed to decode transaction. Saving transaction data hash as JSON") - - err := CreateOrAppendToJsonArray(m.Cfg.revertedTransactionsFile, tx.Hash().Hex()) - if err != nil { - l.Warn(). - Err(err). - Str("TXHash", tx.Hash().Hex()). - Msg("Failed to save reverted transaction hash to file") - } else { - l.Trace(). - Str("TXHash", tx.Hash().Hex()). - Msg("Saved reverted transaction to file") - } - } - - if m.Cfg.hasOutput(TraceOutput_Console) { - m.printDecodedTXData(l, &decoded) - } -} - func (m *Client) handleTracingError(l zerolog.Logger, decoded DecodedTransaction, traceErr, revertErr error) { if m.Cfg.hasOutput(TraceOutput_JSON) { l.Trace(). @@ -416,7 +396,13 @@ func (m *Client) decodeTransaction(l zerolog.Logger, tx *types.Transaction, rece sig := txData[:4] if m.ABIFinder == nil { - l.Err(errors.New("ABIFInder is nil")).Msg("ABIFinder is required for transaction decoding") + err := fmt.Errorf("ABIFinder is not initialized, cannot decode transaction. " + + "This is an internal error - ABIFinder should be set during client initialization.\n" + + "If you see this error:\n" + + " 1. Ensure you're using NewClient() or NewClientWithConfig() to create the client\n" + + " 2. Don't manually modify client.ABIFinder\n" + + " 3. If the issue persists, please open a GitHub issue at https://github.com/smartcontractkit/chainlink-testing-framework/issues") + l.Err(err).Msg("ABIFinder is required for transaction decoding") return defaultTxn, nil } @@ -434,10 +420,16 @@ func (m *Client) decodeTransaction(l zerolog.Logger, tx *types.Transaction, rece txInput, err = decodeTxInputs(l, txData, abiResult.Method) if err != nil { - return defaultTxn, errors.Wrap(err, ErrDecodeInput) + return defaultTxn, fmt.Errorf("failed to decode transaction input for method '%s': %w\n"+ + "This could be due to:\n"+ + " 1. Transaction data doesn't match the ABI method signature\n"+ + " 2. Incorrect ABI for this contract\n"+ + " 3. Malformed transaction data", + abiResult.Method.Name, err) } var txIndex uint + var decodeErrors []EventDecodingError if receipt != nil { l.Trace().Interface("Receipt", receipt).Msg("TX receipt") @@ -453,7 +445,8 @@ func (m *Client) decodeTransaction(l zerolog.Logger, tx *types.Transaction, rece allABIs = m.ContractStore.GetAllABIs() } - txEvents, err = m.decodeContractLogs(l, logsValues, allABIs) + var err error + txEvents, decodeErrors, err = m.decodeContractLogs(l, logsValues, allABIs) if err != nil { return defaultTxn, err } @@ -465,12 +458,13 @@ func (m *Client) decodeTransaction(l zerolog.Logger, tx *types.Transaction, rece Method: abiResult.Method.Sig, Input: txInput, }, - Index: txIndex, - Receipt: receipt, - Transaction: tx, - Protected: tx.Protected(), - Hash: tx.Hash().String(), - Events: txEvents, + Index: txIndex, + Receipt: receipt, + Transaction: tx, + Protected: tx.Protected(), + Hash: tx.Hash().String(), + Events: txEvents, + EventDecodingErrors: decodeErrors, } return ptx, nil @@ -489,7 +483,34 @@ func (m *Client) printDecodedTXData(l zerolog.Logger, ptx *DecodedTransaction) { for _, e := range ptx.Events { l.Debug(). Str("Signature", e.Signature). - Interface("Log", e.EventData).Send() + Str("Address", e.Address.Hex()). + Interface("Topics", e.Topics). + Interface("Data", e.EventData). + Msg("Event emitted") + } + + // Print event decoding errors separately + if len(ptx.EventDecodingErrors) > 0 { + l.Warn(). + Int("Failed event decodes", len(ptx.EventDecodingErrors)). + Msg("Some events could not be decoded") + + for _, decodeErr := range ptx.EventDecodingErrors { + abiNames := make([]string, len(decodeErr.Errors)) + errorMsgs := make([]string, len(decodeErr.Errors)) + for i, abiErr := range decodeErr.Errors { + abiNames[i] = abiErr.ABIName + errorMsgs[i] = fmt.Sprintf("%s.%s: %s", abiErr.ABIName, abiErr.EventName, abiErr.Error) + } + + l.Warn(). + Str("Signature", decodeErr.Signature). + Uint("LogIndex", decodeErr.LogIndex). + Str("Address", decodeErr.Address). + Strs("AttemptedABIs", abiNames). + Strs("Errors", errorMsgs). + Msg("Failed to decode event log") + } } } @@ -498,7 +519,13 @@ func (m *Client) DecodeCustomABIErr(txErr error) (string, error) { //nolint cerr, ok := txErr.(rpc.DataError) if !ok { - return "", errors.New(ErrRPCJSONCastError) + return "", fmt.Errorf("failed to extract revert reason from RPC error response. " + + "The RPC response format is not recognized.\n" + + "This could mean:\n" + + " 1. Your RPC node uses a non-standard error format\n" + + " 2. The transaction didn't revert (unexpected state)\n" + + " 3. RPC node is experiencing issues\n" + + "The transaction trace may still contain useful information") } if m.ContractStore == nil { L.Warn().Msg(WarnNoContractStore) @@ -537,7 +564,9 @@ func (m *Client) CallMsgFromTx(tx *types.Transaction) (ethereum.CallMsg, error) signer := types.LatestSignerForChainID(tx.ChainId()) sender, err := types.Sender(signer, tx) if err != nil { - return ethereum.CallMsg{}, errors.Wrapf(err, "failed to get sender from transaction") + return ethereum.CallMsg{}, fmt.Errorf("failed to get sender from transaction %s: %w\n"+ + "This usually means the transaction signature is invalid or doesn't match the chain ID", + tx.Hash().Hex(), err) } if tx.Type() == types.LegacyTxType { @@ -566,7 +595,12 @@ func (m *Client) CallMsgFromTx(tx *types.Transaction) (ethereum.CallMsg, error) func (m *Client) DownloadContractAndGetPragma(address common.Address, block *big.Int) (Pragma, error) { bytecode, err := m.Client.CodeAt(context.Background(), address, block) if err != nil { - return Pragma{}, errors.Wrap(err, "failed to get contract code") + return Pragma{}, fmt.Errorf("failed to get contract code at address %s (block %s): %w\n"+ + "Ensure:\n"+ + " 1. The address contains a deployed contract\n"+ + " 2. The block number is valid\n"+ + " 3. RPC node is synced and accessible", + address.Hex(), block.String(), err) } pragma, err := DecodePragmaVersion(common.Bytes2Hex(bytecode)) @@ -597,7 +631,7 @@ func (m *Client) callAndGetRevertReason(tx *types.Transaction, rc *types.Receipt return err } if decodedABIErrString != "" { - return errors.New(decodedABIErrString) + return fmt.Errorf("transaction reverted with custom error: %s", decodedABIErrString) } if plainStringErr != nil { @@ -629,7 +663,13 @@ func (m *Client) callAndGetRevertReason(tx *types.Transaction, rc *types.Receipt func decodeTxInputs(l zerolog.Logger, txData []byte, method *abi.Method) (map[string]interface{}, error) { l.Trace().Msg("Parsing tx inputs") if (len(txData)) < 4 { - return nil, errors.New(ErrTooShortTxData) + return nil, fmt.Errorf("transaction data is too short to contain a valid function call. "+ + "Expected at least 4 bytes for function selector, got %d bytes.\n"+ + "This might indicate:\n"+ + " 1. Plain ETH transfer (no function call)\n"+ + " 2. Invalid/corrupted transaction data\n"+ + " 3. Contract deployment (not a function call)", + len(txData)) } inputMap := make(map[string]interface{}) @@ -665,7 +705,9 @@ func decodeTxOutputs(l zerolog.Logger, payload []byte, method *abi.Method) (map[ } else { err := method.Outputs.UnpackIntoMap(outputMap, payload) if err != nil { - return nil, errors.Wrap(err, ErrDecodeOutput) + return nil, fmt.Errorf("failed to decode transaction output for method '%s': %w\n"+ + "The output data doesn't match the expected ABI return types", + method.Name, err) } } l.Trace().Interface("Outputs", outputMap).Msg("Transaction outputs") @@ -690,7 +732,9 @@ func decodeEventFromLog( if len(lo.GetData()) != 0 { err := a.UnpackIntoMap(eventsMap, eventABISpec.Name, lo.GetData()) if err != nil { - return nil, nil, errors.Wrap(err, ErrDecodedLogNonIndexed) + return nil, nil, fmt.Errorf("failed to decode non-indexed log data for event '%s': %w\n"+ + "The log data doesn't match the expected event signature", + eventABISpec.Name, err) } l.Trace().Interface("Non-indexed", eventsMap).Send() } @@ -715,7 +759,9 @@ func decodeEventFromLog( l.Trace().Interface("Indexed", indexed).Send() err := abi.ParseTopicsIntoMap(topicsMap, indexed, indexedTopics) if err != nil { - return nil, nil, errors.Wrap(err, ErrDecodeILogIndexed) + return nil, nil, fmt.Errorf("failed to decode indexed log topics for event '%s': %w\n"+ + "The indexed topic data doesn't match the expected event indexed parameters", + eventABISpec.Name, err) } l.Trace().Interface("Indexed", topicsMap).Send() } diff --git a/seth/gas.go b/seth/gas.go index 6f3e6f743..a89dbb79e 100644 --- a/seth/gas.go +++ b/seth/gas.go @@ -6,7 +6,6 @@ import ( "math/big" "github.com/montanaflynn/stats" - "github.com/pkg/errors" ) // GasEstimator estimates gas prices @@ -27,23 +26,40 @@ func (m *GasEstimator) Stats(ctx context.Context, blockCount uint64, priorityPer estimations := GasSuggestions{} if blockCount == 0 { - return estimations, errors.New("block count must be greater than zero") + return estimations, fmt.Errorf("block count must be greater than zero for gas estimation. "+ + "Check 'gas_price_estimation_blocks' in your config (seth.toml or ClientBuilder) - current value: %d", blockCount) } currentBlock, err := m.Client.Client.BlockNumber(ctx) if err != nil { - return GasSuggestions{}, fmt.Errorf("failed to get current block number: %w", err) + return GasSuggestions{}, fmt.Errorf("failed to get current block number: %w\n"+ + "Ensure RPC endpoint is accessible and synced. "+ + "Block history-based gas estimation requires access to recent block data. "+ + "Alternatively, set 'gas_price_estimation_blocks = 0' to disable block-based estimation", + err) } + if currentBlock == 0 { - return GasSuggestions{}, errors.New("current block number is zero. No fee history available") + return GasSuggestions{}, fmt.Errorf("current block number is zero, which indicates either:\n" + + " 1. The network hasn't produced any blocks yet (check if network is running)\n" + + " 2. RPC node is not synced\n" + + " 3. Connection to RPC node failed\n" + + "Block history-based gas estimation is not possible without block history. " + + "You can set 'gas_price_estimation_blocks = 0' to disable block-based estimation") } if blockCount >= currentBlock { - blockCount = currentBlock - 1 + blockCount = max(currentBlock-1, 1) // avoid a case, when we ask for more blocks than exist and when currentBlock = 1 } hist, err := m.Client.Client.FeeHistory(ctx, blockCount, big.NewInt(mustSafeInt64(currentBlock)), []float64{priorityPerc}) if err != nil { - return GasSuggestions{}, fmt.Errorf("failed to get fee history: %w", err) + return GasSuggestions{}, fmt.Errorf("failed to get fee history for %d blocks: %w\n"+ + "Possible causes:\n"+ + " 1. RPC node doesn't support eth_feeHistory\n"+ + " 2. Not enough blocks available (current block: %d)\n"+ + " 3. Network connection issues\n"+ + "Try reducing 'gas_price_estimation_blocks' in config", + blockCount, err, currentBlock) } L.Trace(). Interface("History", hist). @@ -60,7 +76,10 @@ func (m *GasEstimator) Stats(ctx context.Context, blockCount uint64, priorityPer } gasPercs, err := quantilesFromFloatArray(baseFees) if err != nil { - return GasSuggestions{}, fmt.Errorf("failed to calculate quantiles from fee history for base fee: %w", err) + return GasSuggestions{}, fmt.Errorf("failed to calculate gas price quantiles from %d blocks of fee history: %w\n"+ + "This might indicate insufficient or invalid fee data. "+ + "Try reducing 'gas_price_estimation_blocks' in config", + len(baseFees), err) } estimations.BaseFeePerc = gasPercs @@ -82,7 +101,10 @@ func (m *GasEstimator) Stats(ctx context.Context, blockCount uint64, priorityPer } tipPercs, err := quantilesFromFloatArray(tips) if err != nil { - return GasSuggestions{}, fmt.Errorf("failed to calculate quantiles from fee history for tip cap: %w", err) + return GasSuggestions{}, fmt.Errorf("failed to calculate tip cap quantiles from %d blocks of fee history: %w\n"+ + "This might indicate insufficient or invalid tip data. "+ + "Try reducing 'gas_price_estimation_blocks' in config", + len(tips), err) } estimations.TipCapPerc = tipPercs L.Trace(). @@ -91,20 +113,32 @@ func (m *GasEstimator) Stats(ctx context.Context, blockCount uint64, priorityPer suggestedGasPrice, err := m.Client.Client.SuggestGasPrice(ctx) if err != nil { - return GasSuggestions{}, fmt.Errorf("failed to get suggested gas price: %w", err) + return GasSuggestions{}, fmt.Errorf("failed to get suggested gas price from RPC: %w\n"+ + "Possible solutions:\n"+ + " 1. Disable gas estimation and set explicit 'gas_price' in config (gas_price_estimation_enabled = false)\n"+ + " 2. Check RPC node capabilities and accessibility\n"+ + " 3. Verify the network supports gas price queries", + err) } estimations.SuggestedGasPrice = suggestedGasPrice suggestedGasTipCap, err := m.Client.Client.SuggestGasTipCap(ctx) if err != nil { - return GasSuggestions{}, fmt.Errorf("failed to get suggested gas tip cap: %w", err) + return GasSuggestions{}, fmt.Errorf("failed to get suggested gas tip cap from RPC: %w\n"+ + "Possible solutions:\n"+ + " 1. Disable gas estimation and set explicit 'gas_tip_cap' in config (gas_price_estimation_enabled = false)\n"+ + " 2. Check if network supports EIP-1559\n"+ + " 3. Verify RPC node capabilities", + err) } estimations.SuggestedGasTipCap = suggestedGasTipCap header, err := m.Client.Client.HeaderByNumber(ctx, nil) if err != nil { - return GasSuggestions{}, fmt.Errorf("failed to get latest block header: %w", err) + return GasSuggestions{}, fmt.Errorf("failed to get latest block header: %w\n"+ + "Cannot determine current base fee. Check RPC connection", + err) } estimations.LastBaseFee = header.BaseFee diff --git a/seth/gas_adjuster.go b/seth/gas_adjuster.go index 2d5ed8922..c8691390d 100644 --- a/seth/gas_adjuster.go +++ b/seth/gas_adjuster.go @@ -2,17 +2,17 @@ package seth import ( "context" + "errors" "fmt" "math" "math/big" "slices" - "strings" "sync" "time" "github.com/avast/retry-go" "github.com/ethereum/go-ethereum/core/types" - "github.com/pkg/errors" + "go.uber.org/ratelimit" ) const ( @@ -36,17 +36,21 @@ const ( ) var ( - ZeroGasSuggestedErr = "either base fee or suggested tip is 0" - BlockFetchingErr = "failed to fetch enough block headers for congestion calculation" + ErrGasEstimation = errors.New("incorrect gas data received from node. Skipping gas estimation") + ErrBlockFetching = errors.New("failed to fetch enough block headers for congestion calculation") ) // CalculateNetworkCongestionMetric calculates a simple congestion metric based on the last N blocks // according to selected strategy. func (m *Client) CalculateNetworkCongestionMetric(blocksNumber uint64, strategy string) (float64, error) { if m.HeaderCache == nil { - return 0, fmt.Errorf("header cache is nil") + err := fmt.Errorf("header cache is not initialized. " + + "This is an internal error that shouldn't happen. " + + "If you see this, please open a GitHub issue at https://github.com/smartcontractkit/chainlink-testing-framework/issues with your configuration details") + err = fmt.Errorf("%w: %v", ErrBlockFetching, err) + return 0, err } - var getHeaderData = func(bn *big.Int) (*types.Header, error) { + var getHeaderData = func(ctx context.Context, bn *big.Int) (*types.Header, error) { if bn == nil { return nil, fmt.Errorf("block number is nil") } @@ -55,14 +59,14 @@ func (m *Client) CalculateNetworkCongestionMetric(blocksNumber uint64, strategy return cachedHeader, nil } - timeout := blocksNumber / 100 + timeout := blocksNumber / 10 if timeout < 3 { timeout = 3 } else if timeout > 6 { timeout = 6 } - ctx, cancel := context.WithTimeout(context.Background(), time.Duration(mustSafeInt64(timeout))*time.Second) + ctx, cancel := context.WithTimeout(ctx, time.Duration(mustSafeInt64(timeout))*time.Second) defer cancel() header, err := m.Client.HeaderByNumber(ctx, bn) if err != nil { @@ -77,13 +81,15 @@ func (m *Client) CalculateNetworkCongestionMetric(blocksNumber uint64, strategy defer cancel() lastBlockNumber, err := m.Client.BlockNumber(ctx) if err != nil { + err = fmt.Errorf("%w: %v", ErrBlockFetching, err) return 0, err } L.Trace().Msgf("Block range for gas calculation: %d - %d", lastBlockNumber-blocksNumber, lastBlockNumber) - lastBlock, err := getHeaderData(big.NewInt(mustSafeInt64(lastBlockNumber))) + lastBlock, err := getHeaderData(ctx, big.NewInt(mustSafeInt64(lastBlockNumber))) if err != nil { + err = fmt.Errorf("%w: %v", ErrBlockFetching, err) return 0, err } @@ -92,6 +98,7 @@ func (m *Client) CalculateNetworkCongestionMetric(blocksNumber uint64, strategy var wg sync.WaitGroup dataCh := make(chan *types.Header) + defer close(dataCh) // defer in case something panics, once it is closed then draining goroutine will exit go func() { for header := range dataCh { @@ -101,6 +108,7 @@ func (m *Client) CalculateNetworkCongestionMetric(blocksNumber uint64, strategy } }() + limit := ratelimit.New(4) // 4 concurrent requests startTime := time.Now() for i := lastBlockNumber; i > lastBlockNumber-blocksNumber; i-- { // better safe than sorry (might happen for brand-new chains) @@ -110,7 +118,8 @@ func (m *Client) CalculateNetworkCongestionMetric(blocksNumber uint64, strategy wg.Add(1) go func(bn *big.Int) { - header, err := getHeaderData(bn) + limit.Take() + header, err := getHeaderData(ctx, bn) if err != nil { L.Debug().Msgf("Failed to get block %d header due to: %s", bn.Int64(), err.Error()) wg.Done() @@ -121,14 +130,27 @@ func (m *Client) CalculateNetworkCongestionMetric(blocksNumber uint64, strategy } wg.Wait() - close(dataCh) endTime := time.Now() L.Debug().Msgf("Time to fetch %d block headers: %v", blocksNumber, endTime.Sub(startTime)) minBlockCount := int(float64(blocksNumber) * 0.8) if len(headers) < minBlockCount { - return 0, fmt.Errorf("%s. Wanted at least %d, got %d", BlockFetchingErr, minBlockCount, len(headers)) + err := fmt.Errorf("failed to fetch sufficient block headers for gas estimation. "+ + "Needed at least %d blocks, but only got %d (%.1f%% success rate).\n"+ + "This usually indicates:\n"+ + " 1. RPC node is experiencing high latency or load\n"+ + " 2. Network connectivity issues\n"+ + " 3. RPC rate limiting\n"+ + "Solutions:\n"+ + " 1. Retry the transaction (temporary RPC issue)\n"+ + " 2. Use a different RPC endpoint\n"+ + " 3. Disable gas estimation: set gas_price_estimation_enabled = false\n"+ + " 4. Reduce gas_price_estimation_blocks to fetch fewer blocks", + minBlockCount, len(headers), float64(len(headers))/float64(blocksNumber)*100) + err = fmt.Errorf("%w: %v", ErrBlockFetching, err) + + return 0, err } switch strategy { @@ -137,7 +159,12 @@ func (m *Client) CalculateNetworkCongestionMetric(blocksNumber uint64, strategy case CongestionStrategy_NewestFirst: return calculateNewestFirstNetworkCongestionMetric(headers), nil default: - return 0, fmt.Errorf("unknown congestion strategy: %s", strategy) + err := fmt.Errorf("unknown network congestion strategy '%s'. "+ + "Valid strategies are: 'simple' (equal weight) or 'newest_first' (recent blocks weighted more).\n"+ + "This is likely a configuration error. Check your gas estimation settings", + strategy) + err = fmt.Errorf("%w: %v", ErrBlockFetching, err) + return 0, err } } @@ -190,6 +217,7 @@ func (m *Client) GetSuggestedEIP1559Fees(ctx context.Context, priority string) ( // fallback to current fees if historical fetching fails baseFee, currentGasTip, err = m.currentIP1559Fees(ctx) if err != nil { + err = fmt.Errorf("%w: %v", ErrGasEstimation, err) return } L.Debug().Msg("Falling back to current EIP-1559 fees for gas estimation") @@ -197,12 +225,19 @@ func (m *Client) GetSuggestedEIP1559Fees(ctx context.Context, priority string) ( } else { baseFee, currentGasTip, err = m.currentIP1559Fees(ctx) if err != nil { + err = fmt.Errorf("%w: %v", ErrGasEstimation, err) return } } // defensive programming if baseFee == nil || currentGasTip == nil { - err = errors.New(ZeroGasSuggestedErr) + err = fmt.Errorf("RPC node returned nil gas price or zero gas tip. " + + "This indicates the node's gas estimation is not working properly.\n" + + "Solutions:\n" + + " 1. Use a different RPC endpoint\n" + + " 2. Disable gas estimation: set gas_price_estimation_enabled = false in config\n" + + " 3. Set explicit gas values: gas_price, gas_fee_cap, and gas_tip_cap (in your config (seth.toml or ClientBuilder)") + err = fmt.Errorf("%w: %v", ErrGasEstimation, err) return } @@ -228,7 +263,7 @@ func (m *Client) GetSuggestedEIP1559Fees(ctx context.Context, priority string) ( L.Debug().Msg("Suggested tip is 0.0. Will use historical base fee as tip.") currentGasTip = big.NewInt(int64(baseFee64)) } - } else if baseFeeTipMagnitudeDiff < 3 { + } else if baseFeeTipMagnitudeDiff < -3 { L.Debug().Msg("Historical base fee is 3 orders of magnitude lower than suggested tip. Will use suggested tip as base fee.") baseFee64 = float64(currentGasTip.Int64()) } else if baseFeeTipMagnitudeDiff > 3 { @@ -242,6 +277,8 @@ func (m *Client) GetSuggestedEIP1559Fees(ctx context.Context, priority string) ( Float64("BaseFee", baseFee64). Int64("SuggestedTip", currentGasTip.Int64()). Msgf("Incorrect gas data received from node: base fee was 0. Skipping gas estimation") + err = errors.New("incorrect gas data received from node: base fee was 0. Skipping gas estimation") + err = fmt.Errorf("%w: %v", ErrGasEstimation, err) return } @@ -258,6 +295,7 @@ func (m *Client) GetSuggestedEIP1559Fees(ctx context.Context, priority string) ( var adjustmentFactor float64 adjustmentFactor, err = getAdjustmentFactor(priority) if err != nil { + err = fmt.Errorf("%w: %v", ErrGasEstimation, err) return } @@ -290,6 +328,7 @@ func (m *Client) GetSuggestedEIP1559Fees(ctx context.Context, priority string) ( var bufferAdjustment float64 bufferAdjustment, err = getCongestionFactor(congestionClassification) if err != nil { + err = fmt.Errorf("%w: %v", ErrGasEstimation, err) return } @@ -300,7 +339,8 @@ func (m *Client) GetSuggestedEIP1559Fees(ctx context.Context, priority string) ( // Apply buffer also to the tip bufferedTipCapFloat := new(big.Float).Mul(new(big.Float).SetInt(adjustedTipCap), big.NewFloat(bufferAdjustment)) adjustedTipCap, _ = bufferedTipCapFloat.Int(nil) - } else if !strings.Contains(err.Error(), BlockFetchingErr) { + } else if !errors.Is(err, ErrBlockFetching) { + err = fmt.Errorf("%w: %v", ErrGasEstimation, err) return } else { L.Debug(). @@ -381,6 +421,16 @@ func (m *Client) eip1559FeesFromHistory(ctx context.Context, priority string) (b err = fmt.Errorf("failed to fetch EIP1559 historical fees: %w", retryErr) return } + + if baseFee64 == 0.0 { + err = fmt.Errorf("historical base fee is 0.0. This might indicate insufficient or invalid fee data from the node.\n" + + "Possible solutions:\n" + + " 1. Reduce 'gas_price_estimation_blocks' in config\n" + + " 2. Check RPC node capabilities and accessibility\n" + + " 3. Verify the network supports EIP-1559 fee history queries") + return + } + baseFee = big.NewInt(int64(baseFee64)) L.Debug(). @@ -446,6 +496,27 @@ func (m *Client) currentIP1559Fees(ctx context.Context) (baseFee *big.Int, tipCa return } + if baseFee == nil || baseFee.Int64() == 0 { + err = fmt.Errorf("RPC node returned base fee of 0, which is invalid for EIP-1559 transactions.\n" + + "This might indicate:\n" + + " 1. Network doesn't support EIP-1559 (use legacy transactions instead)\n" + + " 2. RPC node configuration issue\n" + + "Solution: Disable gas estimation and set explicit gas prices in config:\n" + + " - Set gas_price_estimation_enabled = false\n" + + " - Set gas_fee_cap and gas_tip_cap for EIP-1559 networks\n" + + " - Or use eip1559_dynamic_fees = false to switch to legacy transactions") + return + } + + if tipCap == nil { + err = fmt.Errorf("RPC node returned nil gas tip cap.\n" + + "This indicates an RPC error when fetching EIP-1559 gas suggestions.\n" + + "Solution: Disable gas estimation and set explicit gas prices in config:\n" + + " - Set gas_price_estimation_enabled = false\n" + + " - Set gas_fee_cap and gas_tip_cap for EIP-1559 networks") + return + } + baseFee64, _ := baseFee.Float64() tipCap64, _ := tipCap.Float64() L.Debug(). @@ -472,7 +543,14 @@ func (m *Client) GetSuggestedLegacyFees(ctx context.Context, priority string) (a } if suggestedGasPrice.Int64() == 0 { - return errors.New("suggested gas price is 0") + return fmt.Errorf("RPC node returned gas price of 0, which is invalid.\n" + + "This might indicate:\n" + + " 1. Network doesn't support gas price estimation (some test networks)\n" + + " 2. RPC node configuration issue\n" + + "Solution: Disable gas estimation and set explicit gas prices in config:\n" + + " - Set gas_price_estimation_enabled = false\n" + + " - For EIP-1559 networks: set gas_fee_cap and gas_tip_cap\n" + + " - For legacy networks: set gas_price") } return nil @@ -487,7 +565,7 @@ func (m *Client) GetSuggestedLegacyFees(ctx context.Context, priority string) (a })) if retryErr != nil { - err = retryErr + err = fmt.Errorf("%w: %v", ErrGasEstimation, retryErr) return } @@ -499,6 +577,7 @@ func (m *Client) GetSuggestedLegacyFees(ctx context.Context, priority string) (a var adjustmentFactor float64 adjustmentFactor, err = getAdjustmentFactor(priority) if err != nil { + err = fmt.Errorf("%w: %v", ErrGasEstimation, err) return } @@ -526,13 +605,15 @@ func (m *Client) GetSuggestedLegacyFees(ctx context.Context, priority string) (a var bufferAdjustment float64 bufferAdjustment, err = getCongestionFactor(congestionClassification) if err != nil { + err = fmt.Errorf("%w: %v", ErrGasEstimation, err) return } // Calculate and apply the buffer. bufferedGasPriceFloat := new(big.Float).Mul(new(big.Float).SetInt(adjustedGasPrice), big.NewFloat(bufferAdjustment)) adjustedGasPrice, _ = bufferedGasPriceFloat.Int(nil) - } else if !strings.Contains(err.Error(), BlockFetchingErr) { + } else if !errors.Is(err, ErrBlockFetching) { + err = fmt.Errorf("%w: %v", ErrGasEstimation, err) return } else { L.Debug(). @@ -568,7 +649,10 @@ func getAdjustmentFactor(priority string) (float64, error) { case Priority_Slow: return 0.8, nil default: - return 0, fmt.Errorf("unsupported priority: %s", priority) + return 0, fmt.Errorf("unsupported transaction priority '%s'. "+ + "Valid priorities: 'fast', 'standard', 'slow', 'auto'. "+ + "Set 'gas_price_estimation_tx_priority' in your config (seth.toml or ClientBuilder)", + priority) } } @@ -583,7 +667,10 @@ func getCongestionFactor(congestionClassification string) (float64, error) { case Congestion_VeryHigh: return 1.40, nil default: - return 0, fmt.Errorf("unsupported congestion classification: %s", congestionClassification) + return 0, fmt.Errorf("unsupported congestion classification '%s'. "+ + "Valid classifications: 'low', 'medium', 'high', 'extreme'. "+ + "This is likely an internal error. Please open a GitHub issue at https://github.com/smartcontractkit/chainlink-testing-framework/issues", + congestionClassification) } } @@ -614,7 +701,10 @@ func (m *Client) HistoricalFeeData(ctx context.Context, priority string) (baseFe case Priority_Slow: percentileTip = 25 default: - err = fmt.Errorf("unknown priority: %s", priority) + err = fmt.Errorf("unsupported transaction priority '%s'. "+ + "Valid priorities: 'degen' (internal), 'fast', 'standard', 'slow'. "+ + "Set 'gas_price_estimation_tx_priority' in your config (seth.toml or ClientBuilder)", + priority) L.Debug(). Str("Priority", priority). Msgf("Unknown priority: %s", err.Error()) @@ -644,7 +734,10 @@ func (m *Client) HistoricalFeeData(ctx context.Context, priority string) (baseFe case Priority_Slow: baseFee = stats.BaseFeePerc.Perc25 default: - err = fmt.Errorf("unsupported priority: %s", priority) + err = fmt.Errorf("unsupported transaction priority '%s'. "+ + "Valid priorities: 'degen' (internal), 'fast', 'standard', 'slow'. "+ + "Set 'gas_price_estimation_tx_priority' in your config (seth.toml or ClientBuilder)", + priority) L.Debug(). Str("Priority", priority). Msgf("Unsupported priority: %s", err.Error()) @@ -664,16 +757,18 @@ func calculateGasUsedRatio(headers []*types.Header) float64 { return 0 } - var totalRatio float64 + var totalGasUsedRatio float64 + validHeaders := 0 for _, header := range headers { - if header.GasLimit == 0 { - continue + if header.GasLimit > 0 { + totalGasUsedRatio += float64(header.GasUsed) / float64(header.GasLimit) + validHeaders++ } - ratio := float64(header.GasUsed) / float64(header.GasLimit) - totalRatio += ratio } - averageRatio := totalRatio / float64(len(headers)) - return averageRatio + if validHeaders == 0 { + return 0.0 + } + return totalGasUsedRatio / float64(validHeaders) } func calculateMagnitudeDifference(first, second *big.Float) (int, string) { @@ -692,12 +787,16 @@ func calculateMagnitudeDifference(first, second *big.Float) (int, string) { secondOrderOfMagnitude := math.Log10(secondFloat) diff := firstOrderOfMagnitude - secondOrderOfMagnitude + absDiff := math.Abs(diff) + + // Values within the same order of magnitude (less than 10x difference) + if absDiff < 1 { + return 0, "the same order of magnitude" + } if diff < 0 { intDiff := math.Floor(diff) return int(intDiff), fmt.Sprintf("%d orders of magnitude smaller", int(math.Abs(intDiff))) - } else if diff > 0 && diff <= 1 { - return 0, "the same order of magnitude" } intDiff := int(math.Ceil(diff)) diff --git a/seth/gas_adjuster_test.go b/seth/gas_adjuster_test.go new file mode 100644 index 000000000..e38d32160 --- /dev/null +++ b/seth/gas_adjuster_test.go @@ -0,0 +1,434 @@ +package seth + +import ( + "math" + "math/big" + "testing" +) + +// TestCalculateMagnitudeDifference tests the magnitude difference calculation +func TestGasAdjuster_CalculateMagnitudeDifference(t *testing.T) { + tests := []struct { + name string + first *big.Float + second *big.Float + expectedDiff int + expectedText string + }{ + { + name: "First much larger (5 orders)", + first: big.NewFloat(100_000_000_000), // 100 gwei + second: big.NewFloat(1_000_000), // 0.001 gwei + expectedDiff: 5, + expectedText: "5 orders of magnitude larger", + }, + { + name: "First much smaller (4 orders)", + first: big.NewFloat(1_000_000), // 0.001 gwei + second: big.NewFloat(10_000_000_000), // 10 gwei + expectedDiff: -4, + expectedText: "4 orders of magnitude smaller", + }, + { + name: "Similar magnitude (within 1 order)", + first: big.NewFloat(30_000_000_000), // 30 gwei + second: big.NewFloat(31_000_000_000), // 31 gwei + expectedDiff: 0, + expectedText: "the same order of magnitude", + }, + { + name: "Similar magnitude (within 1 order)", + first: big.NewFloat(100_000_000_000), // 100 gwei + second: big.NewFloat(99_999_999_999), // 99.999999999 gwei + expectedDiff: 0, + expectedText: "the same order of magnitude", + }, + { + name: "Similar magnitude (within 1 order)", + first: big.NewFloat(30_000_000_000), // 30 gwei + second: big.NewFloat(99_999_999_999), // 99.999999999 gwei + expectedDiff: 0, + expectedText: "the same order of magnitude", + }, + { + name: "Similar magnitude (within 1 order)", + first: big.NewFloat(99_999_999_999), // 99.999999999 gwei + second: big.NewFloat(30_000_000_000), // 30 gwei + expectedDiff: 0, + expectedText: "the same order of magnitude", + }, + { + name: "Similar magnitude (within 1 order)", + first: big.NewFloat(99_999_999_999), // 99.999999999 gwei + second: big.NewFloat(100_000_000_000), // 100 gwei + expectedDiff: 0, + expectedText: "the same order of magnitude", + }, + { + name: "Just under 1 order of magnitude (same order)", + first: big.NewFloat(9_999_999_999), // 9.999... gwei + second: big.NewFloat(1_000_000_000), // 1 gwei + expectedDiff: 0, // Still same order (diff = 0.9999...) + expectedText: "the same order of magnitude", + }, + { + name: "Exactly 3 orders larger", + first: big.NewFloat(1_000_000_000), // 1 gwei + second: big.NewFloat(1_000_000), // 0.001 gwei + expectedDiff: 3, + expectedText: "3 orders of magnitude larger", + }, + { + name: "Exactly 3 orders smaller", + first: big.NewFloat(1_000_000), // 0.001 gwei + second: big.NewFloat(1_000_000_000), // 1 gwei + expectedDiff: -3, + expectedText: "3 orders of magnitude smaller", + }, + { + name: "First is zero", + first: big.NewFloat(0), + second: big.NewFloat(1_000_000), + expectedDiff: -0, + expectedText: "infinite orders of magnitude smaller", + }, + { + name: "Second is zero", + first: big.NewFloat(1_000_000), + second: big.NewFloat(0), + expectedDiff: -0, + expectedText: "infinite orders of magnitude larger", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + diff, text := calculateMagnitudeDifference(tt.first, tt.second) + + if diff != tt.expectedDiff { + t.Errorf("calculateMagnitudeDifference(first=%s wei, second=%s wei)\n diff = %v, want %v", + tt.first.Text('f', 0), tt.second.Text('f', 0), diff, tt.expectedDiff) + } + + if text != tt.expectedText { + t.Errorf("calculateMagnitudeDifference(first=%s wei, second=%s wei)\n text = %q, want %q", + tt.first.Text('f', 0), tt.second.Text('f', 0), text, tt.expectedText) + } + }) + } +} + +// TestFeeEqualizerLogic tests the Fee Equalizer experimental logic +// WARNING: This feature should ONLY be used on testnets, never on mainnet! +// It can cause massive overpayment or transaction failures on production networks. +func TestGasAdjuster_FeeEqualizerLogic(t *testing.T) { + tests := []struct { + name string + baseFee int64 // wei + tip int64 // wei + expectedBaseFee int64 // wei after adjustment + expectedTip int64 // wei after adjustment + shouldAdjustBaseFee bool + shouldAdjustTip bool + description string + }{ + { + name: "Base fee MUCH smaller than tip (testnet scenario)", + baseFee: 1_000_000, // 0.001 gwei + tip: 5_000_000_000, // 5 gwei + expectedBaseFee: 5_000_000_000, // Should be raised to tip value + expectedTip: 5_000_000_000, // No change + shouldAdjustBaseFee: true, + shouldAdjustTip: false, + description: "Testnet with very low base fee, high suggested tip - raise base fee to avoid tx failure", + }, + { + name: "Base fee MUCH larger than tip (congested testnet)", + baseFee: 100_000_000_000, // 100 gwei + tip: 1_000_000, // 0.001 gwei + expectedBaseFee: 100_000_000_000, // No change + expectedTip: 100_000_000_000, // Should be raised to base fee value + shouldAdjustBaseFee: false, + shouldAdjustTip: true, + description: "High congestion with tiny tip - raise tip to match base fee", + }, + { + name: "Similar magnitude - no adjustment needed", + baseFee: 30_000_000_000, // 30 gwei + tip: 2_000_000_000, // 2 gwei + expectedBaseFee: 30_000_000_000, // No change + expectedTip: 2_000_000_000, // No change + shouldAdjustBaseFee: false, + shouldAdjustTip: false, + description: "Both values are within acceptable range, no adjustment", + }, + { + name: "Exactly at threshold - 3 orders difference (smaller)", + baseFee: 1_000_000, // 0.001 gwei + tip: 1_000_000_000, // 1 gwei + expectedBaseFee: 1_000_000, // No change (exactly 3 orders, not >3) + expectedTip: 1_000_000_000, // No change + shouldAdjustBaseFee: false, + shouldAdjustTip: false, + description: "Exactly 3 orders of magnitude difference - no adjustment at boundary", + }, + { + name: "Exactly at threshold - 3 orders difference (larger)", + baseFee: 1_000_000_000, // 1 gwei + tip: 1_000_000, // 0.001 gwei + expectedBaseFee: 1_000_000_000, // No change (exactly 3 orders, not >3) + expectedTip: 1_000_000, // No change + shouldAdjustBaseFee: false, + shouldAdjustTip: false, + description: "Exactly 3 orders of magnitude difference - no adjustment at boundary", + }, + { + name: "Slightly over threshold - 4 orders (smaller)", + baseFee: 100_000, // 0.0001 gwei + tip: 1_000_000_000, // 1 gwei + expectedBaseFee: 1_000_000_000, // Adjusted + expectedTip: 1_000_000_000, // No change + shouldAdjustBaseFee: true, + shouldAdjustTip: false, + description: "4 orders of magnitude smaller - triggers adjustment", + }, + { + name: "Slightly over threshold - 4 orders (larger)", + baseFee: 1_000_000_000, // 1 gwei + tip: 100_000, // 0.0001 gwei + expectedBaseFee: 1_000_000_000, // No change + expectedTip: 1_000_000_000, // Adjusted + shouldAdjustBaseFee: false, + shouldAdjustTip: true, + description: "4 orders of magnitude larger - triggers adjustment", + }, + { + name: "Base fee is zero (edge case)", + baseFee: 0, + tip: 1_000_000_000, // 1 gwei + expectedBaseFee: 1_000_000_000, // Should use tip + expectedTip: 1_000_000_000, // No change + shouldAdjustBaseFee: true, + shouldAdjustTip: false, + description: "Zero base fee - use tip as base fee", + }, + { + name: "Tip is zero (edge case)", + baseFee: 1_000_000_000, // 1 gwei + tip: 0, + expectedBaseFee: 1_000_000_000, // No change + expectedTip: 1_000_000_000, // Should use base fee + shouldAdjustBaseFee: false, + shouldAdjustTip: true, + description: "Zero tip - use base fee as tip", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Simulate the Fee Equalizer logic + baseFee64 := float64(tt.baseFee) + currentGasTip := big.NewInt(tt.tip) + + baseFeeTipMagnitudeDiff, _ := calculateMagnitudeDifference( + big.NewFloat(baseFee64), + new(big.Float).SetInt(currentGasTip), + ) + + // Track if adjustments were made + baseFeeAdjusted := false + tipAdjusted := false + + // Apply the Fee Equalizer logic + if baseFeeTipMagnitudeDiff == -0 { + if baseFee64 == 0.0 { + baseFee64 = float64(currentGasTip.Int64()) + baseFeeAdjusted = true + } else { + currentGasTip = big.NewInt(int64(baseFee64)) + tipAdjusted = true + } + } else if baseFeeTipMagnitudeDiff < -3 { + // Base fee is MUCH SMALLER than tip (more than 3 orders of magnitude) + baseFee64 = float64(currentGasTip.Int64()) + baseFeeAdjusted = true + } else if baseFeeTipMagnitudeDiff > 3 { + // Base fee is MUCH LARGER than tip (more than 3 orders of magnitude) + currentGasTip = big.NewInt(int64(baseFee64)) + tipAdjusted = true + } + + // Verify results + resultBaseFee := int64(baseFee64) + resultTip := currentGasTip.Int64() + + if resultBaseFee != tt.expectedBaseFee { + t.Errorf("Base fee after adjustment = %d wei (%.4f gwei), want %d wei (%.4f gwei)\nDescription: %s", + resultBaseFee, float64(resultBaseFee)/1e9, + tt.expectedBaseFee, float64(tt.expectedBaseFee)/1e9, + tt.description) + } + + if resultTip != tt.expectedTip { + t.Errorf("Tip after adjustment = %d wei (%.4f gwei), want %d wei (%.4f gwei)\nDescription: %s", + resultTip, float64(resultTip)/1e9, + tt.expectedTip, float64(tt.expectedTip)/1e9, + tt.description) + } + + if baseFeeAdjusted != tt.shouldAdjustBaseFee { + t.Errorf("Base fee adjustment = %v, want %v\nDescription: %s", + baseFeeAdjusted, tt.shouldAdjustBaseFee, tt.description) + } + + if tipAdjusted != tt.shouldAdjustTip { + t.Errorf("Tip adjustment = %v, want %v\nDescription: %s", + tipAdjusted, tt.shouldAdjustTip, tt.description) + } + }) + } +} + +// TestFeeEqualizerDisasterScenarios tests scenarios that would be catastrophic on mainnet +// These tests document WHY this feature should NEVER be used on production networks +func TestGasAdjuster_FeeEqualizerDisasterScenarios(t *testing.T) { + tests := []struct { + name string + baseFee int64 + tip int64 + network string + disasterOutcome string + }{ + { + name: "Ethereum mainnet high gas", + baseFee: 100_000_000_000, // 100 gwei (typical high congestion) + tip: 1_000_000, // 0.001 gwei (would never happen naturally) + network: "Ethereum Mainnet", + disasterOutcome: "Tip raised to 100 gwei - would cost $100+ per transaction!", + }, + { + name: "Polygon PoS normal operation", + baseFee: 30_000_000_000, // 30 gwei + tip: 100_000, // Very low tip + network: "Polygon", + disasterOutcome: "Massive overpayment for network that normally costs fractions of a cent", + }, + { + name: "Arbitrum during congestion", + baseFee: 500_000_000_000, // 500 gwei + tip: 1_000_000, // 0.001 gwei + network: "Arbitrum", + disasterOutcome: "Tip becomes 500 gwei - would bankrupt users on a rollup!", + }, + } + + t.Log("⚠️ WARNING: Fee Equalizer Disaster Scenarios ⚠️") + t.Log("These scenarios demonstrate why Fee Equalizer should NEVER be enabled on mainnet!") + t.Log("") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + baseFee64 := float64(tt.baseFee) + currentGasTip := big.NewInt(tt.tip) + + baseFeeTipMagnitudeDiff, diffText := calculateMagnitudeDifference( + big.NewFloat(baseFee64), + new(big.Float).SetInt(currentGasTip), + ) + + originalTip := currentGasTip.Int64() + + // Apply Fee Equalizer logic + if baseFeeTipMagnitudeDiff > 3 { + currentGasTip = big.NewInt(int64(baseFee64)) + } + + resultTip := currentGasTip.Int64() + overpaymentMultiplier := float64(resultTip) / float64(originalTip) + + t.Logf("Network: %s", tt.network) + t.Logf("Original Base Fee: %d wei (%.2f gwei)", tt.baseFee, float64(tt.baseFee)/1_000_000_000) + t.Logf("Original Tip: %d wei (%.6f gwei)", originalTip, float64(originalTip)/1_000_000_000) + t.Logf("Magnitude Difference: %s", diffText) + t.Logf("Adjusted Tip: %d wei (%.2f gwei)", resultTip, float64(resultTip)/1_000_000_000) + t.Logf("Overpayment Multiplier: %.0fx", overpaymentMultiplier) + t.Logf("Disaster Outcome: %s", tt.disasterOutcome) + t.Logf("") + + // Assert that this would be a disaster + if overpaymentMultiplier > 1000 { + t.Logf("✗ DISASTER: This would cause >1000x overpayment on %s!", tt.network) + } + }) + } +} + +// TestGasAdjusterBoundaryConditions tests edge cases around the 3 order of magnitude threshold +func TestGasAdjuster_FeeEqualizerBoundaryConditions(t *testing.T) { + tests := []struct { + name string + baseFee float64 + tip float64 + expectedAdjustment string + }{ + { + name: "Exactly 3.0 orders (1000x) - no adjustment", + baseFee: 1_000_000_000.0, // 1 gwei + tip: 1_000_000.0, // 0.001 gwei + expectedAdjustment: "none", + }, + { + name: "Just over 3 orders (3.01) - should adjust", + baseFee: 1_023_000_000.0, // 1.023 gwei + tip: 1_000_000.0, // 0.001 gwei + expectedAdjustment: "tip", + }, + { + name: "Just under 3 orders (2.99) - no adjustment", + baseFee: 977_000_000.0, // 0.977 gwei + tip: 1_000_000.0, // 0.001 gwei + expectedAdjustment: "none", + }, + { + name: "Negative: Exactly -3.0 orders - no adjustment", + baseFee: 1_000_000.0, // 0.001 gwei + tip: 1_000_000_000.0, // 1 gwei + expectedAdjustment: "none", + }, + { + name: "Negative: Just over -3 orders (-3.01) - should adjust", + baseFee: 977_000.0, // 0.000977 gwei + tip: 1_000_000_000.0, // 1 gwei + expectedAdjustment: "baseFee", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + baseFee64 := tt.baseFee + currentGasTip := big.NewInt(int64(tt.tip)) + + diff, _ := calculateMagnitudeDifference( + big.NewFloat(baseFee64), + new(big.Float).SetInt(currentGasTip), + ) + + actualAdjustment := "none" + if diff < -3 { + actualAdjustment = "baseFee" + } else if diff > 3 { + actualAdjustment = "tip" + } + + magnitudeDiff := math.Log10(baseFee64) - math.Log10(tt.tip) + t.Logf("Magnitude difference: %.4f orders (threshold: ±3.0)", magnitudeDiff) + t.Logf("Integer diff: %d", diff) + t.Logf("Expected adjustment: %s", tt.expectedAdjustment) + t.Logf("Actual adjustment: %s", actualAdjustment) + + if actualAdjustment != tt.expectedAdjustment { + t.Errorf("Adjustment = %v, want %v", actualAdjustment, tt.expectedAdjustment) + } + }) + } +} diff --git a/seth/go.mod b/seth/go.mod index 6c62211ee..446772cb8 100644 --- a/seth/go.mod +++ b/seth/go.mod @@ -10,7 +10,6 @@ require ( github.com/holiman/uint256 v1.3.2 github.com/montanaflynn/stats v0.7.1 github.com/pelletier/go-toml/v2 v2.2.3 - github.com/pkg/errors v0.9.1 github.com/rs/zerolog v1.33.0 github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v2 v2.27.5 @@ -74,6 +73,7 @@ require ( github.com/pion/stun/v2 v2.0.0 // indirect github.com/pion/transport/v2 v2.2.1 // indirect github.com/pion/transport/v3 v3.0.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.20.4 // indirect github.com/prometheus/client_model v0.6.1 // indirect diff --git a/seth/header_cache.go b/seth/header_cache.go index c1752c9d3..a5a152ccd 100644 --- a/seth/header_cache.go +++ b/seth/header_cache.go @@ -44,7 +44,9 @@ func (c *LFUHeaderCache) Get(blockNumber int64) (*types.Header, bool) { // Set adds or updates a header in the cache. func (c *LFUHeaderCache) Set(header *types.Header) error { if header == nil { - return fmt.Errorf("header is nil") + return fmt.Errorf("cannot add nil header to cache. " + + "This indicates a bug in Seth or the calling code. " + + "Please report this issue at https://github.com/smartcontractkit/chainlink-testing-framework/issues with the stack trace") } c.mu.Lock() defer c.mu.Unlock() diff --git a/seth/keyfile.go b/seth/keyfile.go index 0fc56fbd1..09e1fade7 100644 --- a/seth/keyfile.go +++ b/seth/keyfile.go @@ -3,12 +3,12 @@ package seth import ( "context" "crypto/ecdsa" + "fmt" "math/big" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/crypto" - "github.com/pkg/errors" "golang.org/x/sync/errgroup" ) @@ -22,7 +22,11 @@ func NewAddress() (string, string, error) { publicKey := privateKey.Public() publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey) if !ok { - return "", "", errors.New("error casting public key to ECDSA") + return "", "", fmt.Errorf("failed to cast generated public key to ECDSA type.\n"+ + "This is an internal error in the crypto.GenerateKey() function.\n"+ + "Expected type: *ecdsa.PublicKey, got: %T\n"+ + "Please report this issue: https://github.com/smartcontractkit/chainlink-testing-framework/issues", + publicKey) } address := crypto.PubkeyToAddress(*publicKeyECDSA).Hex() L.Info(). @@ -49,7 +53,13 @@ func ReturnFunds(c *Client, toAddr string) error { } if len(c.Addresses) == 1 { - return errors.New("No addresses to return funds from. Have you passed correct key file?") + return fmt.Errorf("no ephemeral addresses found to return funds from.\n"+ + "Current addresses count: %d (only root key present)\n"+ + "This indicates either:\n"+ + " 1. Key file doesn't contain ephemeral addresses\n"+ + " 2. Wrong key file was loaded\n"+ + " 3. Ephemeral keys were never created (set 'ephemeral_addresses_number' > 0 in config)", + len(c.Addresses)) } eg, egCtx := errgroup.WithContext(ctx) diff --git a/seth/nonce.go b/seth/nonce.go index 68e967493..06023fa99 100644 --- a/seth/nonce.go +++ b/seth/nonce.go @@ -3,6 +3,7 @@ package seth import ( "context" "crypto/ecdsa" + "fmt" "time" "math/big" @@ -10,12 +11,11 @@ import ( "github.com/avast/retry-go" "github.com/ethereum/go-ethereum/common" - "github.com/pkg/errors" "go.uber.org/ratelimit" ) const ( - ErrKeySyncTimeout = "key sync timeout, consider increasing key_sync_timeout in seth.toml, or increasing the number of keys" + ErrKeySyncTimeout = "key sync timeout, consider increasing key_sync_timeout in config (seth.toml or ClientBuilder), or increasing the number of keys" ErrKeySync = "failed to sync the key" ErrNonce = "failed to get nonce" TimeoutKeyNum = -80001 @@ -41,13 +41,22 @@ type KeyNonce struct { func validateNonceManagerConfig(nonceManagerCfg *NonceManagerCfg) error { if nonceManagerCfg.KeySyncRateLimitSec <= 0 { - return errors.New("key_sync_rate_limit_sec should be positive") + return fmt.Errorf("key_sync_rate_limit_sec must be positive (current: %d). "+ + "This controls how many sync attempts per second are allowed. "+ + "Set it in the 'nonce_manager' section of config (seth.toml or ClientBuilder)", + nonceManagerCfg.KeySyncRateLimitSec) } if nonceManagerCfg.KeySyncTimeout == nil || nonceManagerCfg.KeySyncTimeout.Duration() <= 0 { - return errors.New("key_sync_timeout should be positive") + return fmt.Errorf("key_sync_timeout must be positive (current: %v). "+ + "This is how long to wait for a key to sync before timing out. "+ + "Set it in the 'nonce_manager' section of config (seth.toml or ClientBuilder)", + nonceManagerCfg.KeySyncTimeout) } if nonceManagerCfg.KeySyncRetries <= 0 { - return errors.New("key_sync_retries should be positive") + return fmt.Errorf("key_sync_retries must be positive (current: %d). "+ + "This is how many times to retry syncing a key before giving up. "+ + "Set it in the 'nonce_manager' section of config (seth.toml or ClientBuilder)", + nonceManagerCfg.KeySyncRetries) } return nil @@ -56,13 +65,23 @@ func validateNonceManagerConfig(nonceManagerCfg *NonceManagerCfg) error { // NewNonceManager creates a new nonce manager that tracks nonce for each address func NewNonceManager(cfg *Config, addrs []common.Address, privKeys []*ecdsa.PrivateKey) (*NonceManager, error) { if cfg == nil { - return nil, errors.New(ErrSethConfigIsNil) + return nil, fmt.Errorf("seth configuration is nil. Cannot create nonce manager without valid configuration.\n" + + "This usually means you're trying to create a nonce manager before initializing Seth.\n" + + "Solutions:\n" + + " 1. Use NewClient() or NewClientWithConfig() to create a Seth client first\n" + + " 2. If using ClientBuilder, ensure you call Build() before accessing the nonce manager\n" + + " 3. Check that your configuration file (seth.toml) exists and is valid") } if cfg.NonceManager == nil { - return nil, errors.New(ErrNonceManagerConfigIsNil) + return nil, fmt.Errorf("nonce manager configuration is nil. " + + "Add a [nonce_manager] section to your config (seth.toml) or use ClientBuilder with:\n" + + " - key_sync_rate_limit_per_sec\n" + + " - key_sync_timeout\n" + + " - key_sync_retries\n" + + " - key_sync_retry_delay") } if cfgErr := validateNonceManagerConfig(cfg.NonceManager); cfgErr != nil { - return nil, errors.Wrap(cfgErr, "failed to validate nonce manager config") + return nil, fmt.Errorf("nonce manager configuration validation failed: %w", cfgErr) } nonces := make(map[common.Address]int64) @@ -88,7 +107,7 @@ func (m *NonceManager) UpdateNonces() error { for addr := range m.Nonces { nonce, err := m.Client.Client.NonceAt(context.Background(), addr, nil) if err != nil { - return err + return fmt.Errorf("failed to updated nonces for address '%s': %w", addr, err) } m.Nonces[addr] = mustSafeInt64(nonce) } @@ -121,8 +140,16 @@ func (m *NonceManager) anySyncedKey() int { case <-ctx.Done(): m.Lock() defer m.Unlock() - L.Error().Msg(ErrKeySyncTimeout) - m.Client.Errors = append(m.Client.Errors, errors.New(ErrKeySync)) + timeoutErr := fmt.Errorf("key synchronization timed out after %s. "+ + "This means the nonce couldn't be synchronized before the timeout.\n"+ + "Solutions:\n"+ + " 1. Increase 'key_sync_timeout' in config (seth.toml or ClientBuilder) - current: %s\n"+ + " 2. Reduce 'key_sync_rate_limit_per_sec' to allow faster sync attempts\n"+ + " 3. Add more keys with 'ephemeral_addresses_number'\n"+ + " 4. Check RPC node performance and connectivity", + m.cfg.KeySyncTimeout.Duration(), m.cfg.KeySyncTimeout.Duration()) + L.Error().Msg(timeoutErr.Error()) + m.Client.Errors = append(m.Client.Errors, timeoutErr) return TimeoutKeyNum //so that it's pretty unique number of invalid key case keyData := <-m.SyncedKeys: L.Trace(). @@ -140,7 +167,13 @@ func (m *NonceManager) anySyncedKey() int { Msg("Key is syncing") nonce, err := m.Client.Client.NonceAt(context.Background(), m.Addresses[keyData.KeyNum], nil) if err != nil { - return errors.New(ErrNonce) + return fmt.Errorf("failed to get nonce for address %s (key #%d): %w\n"+ + "This usually indicates:\n"+ + " 1. RPC node connection issues\n"+ + " 2. Network congestion or high latency\n"+ + " 3. Address doesn't exist on the network\n"+ + "Consider increasing key_sync_timeout in your config", + m.Addresses[keyData.KeyNum].Hex(), keyData.KeyNum, err) } if nonce == keyData.Nonce+1 { L.Trace(). @@ -162,13 +195,19 @@ func (m *NonceManager) anySyncedKey() int { Interface("Address", m.Addresses[keyData.KeyNum]). Msg("Key NOT synced") - return errors.New(ErrKeySync) + return fmt.Errorf("key #%d (address: %s) sync failed. "+ + "Expected nonce %d, but got %d. "+ + "This indicates the transaction hasn't been mined yet", + keyData.KeyNum, m.Addresses[keyData.KeyNum].Hex(), + keyData.Nonce+1, nonce) }, retry.Attempts(m.cfg.KeySyncRetries), retry.Delay(m.cfg.KeySyncRetryDelay.Duration()), ) if err != nil { - m.Client.Errors = append(m.Client.Errors, errors.New(ErrKeySync)) + syncErr := fmt.Errorf("failed to sync key #%d after %d retries: %w", + keyData.KeyNum, m.cfg.KeySyncRetries, err) + m.Client.Errors = append(m.Client.Errors, syncErr) } }() return keyData.KeyNum diff --git a/seth/retry.go b/seth/retry.go index ffbe2360d..e878f8f0c 100644 --- a/seth/retry.go +++ b/seth/retry.go @@ -11,7 +11,6 @@ import ( "github.com/avast/retry-go" "github.com/ethereum/go-ethereum/core/types" - "github.com/pkg/errors" ) /* these are the common errors of RPCs */ @@ -42,12 +41,20 @@ func (m *Client) RetryTxAndDecode(f func() (*types.Transaction, error)) (*Decode ) if err != nil { - return &DecodedTransaction{}, errors.New(ErrRetryTimeout) + return &DecodedTransaction{}, fmt.Errorf("transaction retry timed out after multiple attempts. " + + "The RPC connection was repeatedly refused.\n" + + "Troubleshooting:\n" + + " 1. Check if the RPC node is online and accessible\n" + + " 2. Verify network connectivity\n" + + " 3. Try a different RPC endpoint\n" + + " 4. Check if there are firewall/proxy issues") } dt, err := m.Decode(tx, nil) if err != nil { - return &DecodedTransaction{}, errors.Wrap(err, "error decoding transaction") + return &DecodedTransaction{}, fmt.Errorf("error decoding transaction %s: %w\n"+ + "Failed to decode transaction details after waiting for confirmation", + tx.Hash().Hex(), err) } return dt, nil @@ -64,35 +71,32 @@ var NoOpGasBumpStrategyFn = func(previousGasPrice *big.Int) *big.Int { // PriorityBasedGasBumpingStrategyFn is a function that returns a gas bump strategy based on the priority. // For Fast priority it bumps gas price by 30%, for Standard by 15%, for Slow by 5% and for the rest it does nothing. var PriorityBasedGasBumpingStrategyFn = func(priority string) GasBumpStrategyFn { + divisor := big.NewInt(100) + switch priority { case Priority_Degen: // +100% return func(gasPrice *big.Int) *big.Int { - return gasPrice.Mul(gasPrice, big.NewInt(2)) + multiplier := big.NewInt(200) + return new(big.Int).Div(new(big.Int).Mul(gasPrice, multiplier), divisor) } case Priority_Fast: // +30% return func(gasPrice *big.Int) *big.Int { - gasPriceFloat, _ := gasPrice.Float64() - newGasPriceFloat := big.NewFloat(0.0).Mul(big.NewFloat(gasPriceFloat), big.NewFloat(1.3)) - newGasPrice, _ := newGasPriceFloat.Int64() - return big.NewInt(newGasPrice) + multiplier := big.NewInt(130) + return new(big.Int).Div(new(big.Int).Mul(gasPrice, multiplier), divisor) } case Priority_Standard: // 15% return func(gasPrice *big.Int) *big.Int { - gasPriceFloat, _ := gasPrice.Float64() - newGasPriceFloat := big.NewFloat(0.0).Mul(big.NewFloat(gasPriceFloat), big.NewFloat(1.15)) - newGasPrice, _ := newGasPriceFloat.Int64() - return big.NewInt(newGasPrice) + multiplier := big.NewInt(115) + return new(big.Int).Div(new(big.Int).Mul(gasPrice, multiplier), divisor) } case Priority_Slow: // 5% return func(gasPrice *big.Int) *big.Int { - gasPriceFloat, _ := gasPrice.Float64() - newGasPriceFloat := big.NewFloat(0.0).Mul(big.NewFloat(gasPriceFloat), big.NewFloat(1.05)) - newGasPrice, _ := newGasPriceFloat.Int64() - return big.NewInt(newGasPrice) + multiplier := big.NewInt(105) + return new(big.Int).Div(new(big.Int).Mul(gasPrice, multiplier), divisor) } case Priority_Auto: // No bumping for Auto priority @@ -111,7 +115,12 @@ var prepareReplacementTransaction = func(client *Client, tx *types.Transaction) // If original transaction used auto priority, we cannot bump it if client.Cfg.Network.GasPriceEstimationTxPriority == Priority_Auto { - return nil, errors.New("gas bumping is not supported for auto priority transactions") + return nil, fmt.Errorf("gas bumping is not supported when using 'auto' priority. " + + "The 'auto' mode lets the RPC node set gas prices automatically, " + + "which conflicts with manual gas bumping.\n" + + "Solutions:\n" + + " 1. Set gas_price_estimation_tx_priority to 'fast', 'standard', or 'slow'\n" + + " 2. Set gas_bump.retries = 0 to disable gas bumping") } ctxPending, cancelPending := context.WithTimeout(context.Background(), client.Cfg.Network.TxnTimeout.Duration()) @@ -123,7 +132,8 @@ var prepareReplacementTransaction = func(client *Client, tx *types.Transaction) if err != nil && !isPending { L.Debug().Str("Tx hash", tx.Hash().Hex()).Msg("Transaction was confirmed before bumping gas") - return nil, errors.New("transaction was confirmed before bumping gas") + return nil, fmt.Errorf("transaction was already confirmed before gas bumping attempt. " + + "This means the original transaction was mined successfully. No action needed") } signer := types.LatestSignerForChainID(tx.ChainId()) @@ -211,7 +221,9 @@ var prepareReplacementTransaction = func(client *Client, tx *types.Transaction) replacementTx, err = types.SignNewTx(privateKey, signer, txData) case types.BlobTxType: if tx.To() == nil { - return nil, fmt.Errorf("blob tx with nil recipient is not supported") + return nil, fmt.Errorf("blob transaction with nil recipient is not supported for gas bumping. " + + "Blob transactions (EIP-4844) with nil recipients are not standard and cannot be replaced.\n" + + "This is likely a bug - blob transactions should always have a recipient address") } newGasFeeCap := client.Cfg.GasBump.StrategyFn(tx.GasFeeCap()) newGasTipCap := client.Cfg.GasBump.StrategyFn(tx.GasTipCap()) @@ -236,14 +248,38 @@ var prepareReplacementTransaction = func(client *Client, tx *types.Transaction) Str("Gas fee blob diff", fmt.Sprintf("%s wei /%s ether", gasBlobFeeCapDiff, WeiToEther(gasBlobFeeCapDiff).Text('f', -1))). Msg("Bumping gas fee cap and tip cap for Blob transaction") + var value *uint256.Int + if tx.Value() != nil { + var overflow bool + value, overflow = uint256.FromBig(tx.Value()) + if overflow { + return nil, fmt.Errorf("blob transaction value %s overflows uint256", tx.Value().String()) + } + } + + gasFeeCap, overflow := uint256.FromBig(newGasFeeCap) + if overflow { + return nil, fmt.Errorf("gas fee cap %s overflows uint256 after bumping", newGasFeeCap.String()) + } + + gasTipCap, overflow := uint256.FromBig(newGasTipCap) + if overflow { + return nil, fmt.Errorf("gas tip cap %s overflows uint256 after bumping", newGasTipCap.String()) + } + + blobFeeCap, overflow := uint256.FromBig(newBlobFeeCap) + if overflow { + return nil, fmt.Errorf("blob fee cap %s overflows uint256 after bumping", newBlobFeeCap.String()) + } + txData := &types.BlobTx{ Nonce: tx.Nonce(), To: *tx.To(), - Value: uint256.NewInt(tx.Value().Uint64()), + Value: value, Gas: tx.Gas(), - GasFeeCap: uint256.NewInt(newGasFeeCap.Uint64()), - GasTipCap: uint256.NewInt(newGasTipCap.Uint64()), - BlobFeeCap: uint256.NewInt(newBlobFeeCap.Uint64()), + GasFeeCap: gasFeeCap, + GasTipCap: gasTipCap, + BlobFeeCap: blobFeeCap, BlobHashes: tx.BlobHashes(), Data: tx.Data(), } @@ -273,7 +309,14 @@ var prepareReplacementTransaction = func(client *Client, tx *types.Transaction) replacementTx, err = types.SignNewTx(privateKey, signer, txData) default: - return nil, fmt.Errorf("unsupported tx type %d", tx.Type()) + return nil, fmt.Errorf("unsupported transaction type %d for gas bumping. "+ + "Seth currently supports gas bumping for:\n"+ + " - Type 0: Legacy transactions\n"+ + " - Type 1: EIP-2930 (access list) transactions\n"+ + " - Type 2: EIP-1559 (dynamic fee) transactions\n"+ + " - Type 3: Blob transactions (with recipient)\n"+ + "If you need support for this transaction type, please open a GitHub issue at https://github.com/smartcontractkit/chainlink-testing-framework/issues", + tx.Type()) } if err != nil { diff --git a/seth/tracing.go b/seth/tracing.go index cc9929381..2f0864d6d 100644 --- a/seth/tracing.go +++ b/seth/tracing.go @@ -2,6 +2,7 @@ package seth import ( "context" + "errors" "fmt" "strconv" "strings" @@ -11,17 +12,15 @@ import ( "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/rpc" - "github.com/pkg/errors" "github.com/rs/zerolog" ) +var ( + ErrNoFourByteFound = errors.New("no method signatures found in tracing data") +) + const ( - ErrNoTrace = "no trace found" - ErrNoABIMethod = "no ABI method found" - ErrNoAbiFound = "no ABI found in Contract Store" - ErrNoFourByteFound = "no method signatures found in tracing data" - ErrInvalidMethodSignature = "no method signature found or it's not 4 bytes long" - WrnMissingCallTrace = "This call was missing from call trace, but it's signature was present in 4bytes trace. Most data is missing; Call order remains unknown" + WrnMissingCallTrace = "This call was missing from call trace, but it's signature was present in 4bytes trace. Most data is missing; Call order remains unknown" FAILED_TO_DECODE = "failed to decode" UNKNOWN = "unknown" @@ -128,10 +127,12 @@ type Call struct { func NewTracer(cs *ContractStore, abiFinder *ABIFinder, cfg *Config, contractAddressToNameMap ContractMap, addresses []common.Address) (*Tracer, error) { if cfg == nil { - return nil, errors.New("seth config is nil") + return nil, fmt.Errorf("seth configuration is nil. Cannot create tracer without valid configuration.\n" + + "Ensure you're calling NewClient() or NewClientWithConfig() with a valid config") } if cfg.Network == nil { - return nil, errors.New("no Network is set in the config") + return nil, fmt.Errorf("network configuration is not set. Cannot create tracer without network details.\n" + + "Ensure your config has a valid [network] section or use ClientBuilder.WithNetwork()") } ctx, cancel := context.WithTimeout(context.Background(), cfg.Network.DialTimeout.Duration()) @@ -188,7 +189,15 @@ func (t *Tracer) TraceGethTX(txHash string) ([]*DecodedCall, error) { func (t *Tracer) PrintTXTrace(txHash string) error { trace := t.getTrace(txHash) if trace == nil { - return errors.New(ErrNoTrace) + return fmt.Errorf("no trace data available for this transaction. " + + "This usually means:\n" + + " 1. RPC node doesn't support debug_traceTransaction\n" + + " 2. Transaction hasn't been mined yet\n" + + " 3. Trace data was cleaned up (old transaction)\n" + + "Solutions:\n" + + " 1. Use an archive node or node with tracing enabled\n" + + " 2. Wait for transaction to be mined\n" + + " 3. Set tracing_level = 'NONE' to disable tracing") } l := L.With().Str("Transaction", txHash).Logger() l.Trace().Interface("4Byte", trace.FourByte).Msg("Calls function signatures (names)") @@ -250,18 +259,22 @@ func (t *Tracer) DecodeTrace(l zerolog.Logger, trace Trace) ([]*DecodedCall, err // we can still decode the calls without 4byte signatures if len(trace.FourByte) == 0 { - L.Debug().Msg(ErrNoFourByteFound) + L.Debug().Msg(ErrNoFourByteFound.Error()) } methods := make([]string, 0, len(trace.CallTrace.Calls)+1) var getSignature = func(input string) (string, error) { if len(input) < 10 { - err := errors.New(ErrInvalidMethodSignature) + err := fmt.Errorf("invalid method signature detected in trace. "+ + "Expected 4-byte hex signature (0x12345678), but got invalid format: '%s'.\n"+ + "Unless the transaction is a simple ETH transfer with no data or a fallback function,"+ + "this is likely an internal error. Please open a GitHub issue at https://github.com/smartcontractkit/chainlink-testing-framework/issues with transaction details", + input) l.Err(err). Str("Input", input). Send() - return "", errors.New(ErrInvalidMethodSignature) + return "", err } return input[2:10], nil @@ -317,7 +330,13 @@ func (t *Tracer) DecodeTrace(l zerolog.Logger, trace Trace) ([]*DecodedCall, err for _, call := range calls { methodCounter++ if methodCounter >= len(methods) { - return errors.New("method counter exceeds the number of methods. This indicates there's a logical error in tracing. Please reach out to Test Tooling team") + return fmt.Errorf("internal error: method counter (%d) exceeds available methods (%d). "+ + "This is a bug in the tracing logic.\n"+ + "Please open a GitHub issue at https://github.com/smartcontractkit/chainlink-testing-framework/issues with:\n"+ + " 1. Transaction hash: %s\n"+ + " 2. Your configuration file\n"+ + " 3. Network name and RPC endpoint", + methodCounter, len(methods), trace.TxHash) } methodHex := methods[methodCounter] @@ -458,7 +477,9 @@ func (t *Tracer) decodeCall(byteSignature []byte, rawCall Call) (*DecodedCall, e if rawCall.Output != "" { output, err := hexutil.Decode(rawCall.Output) if err != nil { - return defaultCall, errors.Wrap(err, ErrDecodeOutput) + return defaultCall, fmt.Errorf("failed to decode call output for method '%s': %w\n"+ + "The hex-encoded output is invalid", + abiResult.Method.Name, err) } txOutput, err = decodeTxOutputs(L, output, abiResult.Method) if err != nil { @@ -508,96 +529,102 @@ func (t *Tracer) checkForMissingCalls(trace Trace) []*DecodedCall { actual := countAllTracedCallsFn(trace.CallTrace.Calls, 1) // +1 for the main call diff := expected - actual - if diff != 0 { - L.Debug(). - Int("Debugged calls", actual). - Int("4byte signatures", len(trace.FourByte)). - Msgf("Number of calls and signatures does not match. There were %d more call that were't debugged", diff) - - unknownCall := &DecodedCall{ - CommonData: CommonData{Method: NO_DATA, - Input: map[string]interface{}{"warning": NO_DATA}, - Output: map[string]interface{}{"warning": NO_DATA}, - }, - FromAddress: UNKNOWN, - ToAddress: UNKNOWN, - Events: []DecodedCommonLog{ - {Signature: NO_DATA, EventData: map[string]interface{}{"warning": NO_DATA}}, - }, - } + if diff == 0 { + return []*DecodedCall{} + } - var missingSignatures []string - var findSignatureFn func(fourByteSign string, calls []Call) bool - findSignatureFn = func(fourByteSign string, calls []Call) bool { - for _, c := range calls { - if strings.Contains(c.Input, fourByteSign) { - return true - } + if diff > 0 { + L.Debug(). + Int("Traced calls", actual). + Int("Expected calls (from 4byte signatures)", expected). + Msgf("Call trace mismatch: %d call(s) have signatures but weren't found in trace (possibly optimized out or internal)", diff) + } else { + L.Debug(). + Int("Traced calls", actual). + Int("Expected calls (from 4byte signatures)", expected). + Msgf("Call trace mismatch: %d more call(s) in trace than signatures found (possibly DELEGATECALL or internal calls)", -diff) + } + + unknownCall := &DecodedCall{ + CommonData: CommonData{Method: NO_DATA, + Input: map[string]interface{}{"warning": NO_DATA}, + Output: map[string]interface{}{"warning": NO_DATA}, + }, + FromAddress: UNKNOWN, + ToAddress: UNKNOWN, + Events: []DecodedCommonLog{ + {Signature: NO_DATA, EventData: map[string]interface{}{"warning": NO_DATA}}, + }, + } + + var missingSignatures []string + var findSignatureFn func(fourByteSign string, calls []Call) bool + findSignatureFn = func(fourByteSign string, calls []Call) bool { + for _, c := range calls { + if strings.Contains(c.Input, fourByteSign) { + return true + } - if findSignatureFn(fourByteSign, c.Calls) { - return true - } + if findSignatureFn(fourByteSign, c.Calls) { + return true } + } - return false + return false + } + for k := range trace.FourByte { + if strings.Contains(trace.CallTrace.Input, k) { + continue } - for k := range trace.FourByte { - if strings.Contains(trace.CallTrace.Input, k) { - continue - } - found := findSignatureFn(k, trace.CallTrace.Calls) + found := findSignatureFn(k, trace.CallTrace.Calls) - if !found { - missingSignatures = append(missingSignatures, k) - } + if !found { + missingSignatures = append(missingSignatures, k) } + } - missedCalls := make([]*DecodedCall, 0, len(missingSignatures)) + missedCalls := make([]*DecodedCall, 0, len(missingSignatures)) - for _, missingSig := range missingSignatures { - byteSignature := common.Hex2Bytes(strings.TrimPrefix(missingSig, "0x")) + for _, missingSig := range missingSignatures { + byteSignature := common.Hex2Bytes(strings.TrimPrefix(missingSig, "0x")) - abiResult, err := t.ABIFinder.FindABIByMethod(UNKNOWN, byteSignature) - if err != nil { - L.Info(). - Str("Signature", missingSig). - Msg("Method not found in any ABI instance. Unable to provide any more tracing information") - - missedCalls = append(missedCalls, unknownCall) - continue - } + abiResult, err := t.ABIFinder.FindABIByMethod(UNKNOWN, byteSignature) + if err != nil { + L.Info(). + Str("Signature", missingSig). + Msg("Method not found in any ABI instance. Unable to provide any more tracing information") - toAddress := t.ContractAddressToNameMap.GetContractAddress(abiResult.ContractName()) - comment := WrnMissingCallTrace - if abiResult.DuplicateCount > 0 { - comment = fmt.Sprintf("%s; Potentially inaccurate - method present in %d other contracts", comment, abiResult.DuplicateCount) - } + missedCalls = append(missedCalls, unknownCall) + continue + } - missedCalls = append(missedCalls, &DecodedCall{ - CommonData: CommonData{ - Signature: missingSig, - Method: abiResult.Method.Name, - Input: map[string]interface{}{"warning": NO_DATA}, - Output: map[string]interface{}{"warning": NO_DATA}, - }, - FromAddress: UNKNOWN, - ToAddress: toAddress, - To: abiResult.ContractName(), - From: UNKNOWN, - Comment: comment, - Events: []DecodedCommonLog{ - {Signature: NO_DATA, EventData: map[string]interface{}{"warning": NO_DATA}}, - }, - }) + toAddress := t.ContractAddressToNameMap.GetContractAddress(abiResult.ContractName()) + comment := WrnMissingCallTrace + if abiResult.DuplicateCount > 0 { + comment = fmt.Sprintf("%s; Potentially inaccurate - method present in %d other contracts", comment, abiResult.DuplicateCount) } - return missedCalls + missedCalls = append(missedCalls, &DecodedCall{ + CommonData: CommonData{ + Signature: missingSig, + Method: abiResult.Method.Name, + Input: map[string]interface{}{"warning": NO_DATA}, + Output: map[string]interface{}{"warning": NO_DATA}, + }, + FromAddress: UNKNOWN, + ToAddress: toAddress, + To: abiResult.ContractName(), + From: UNKNOWN, + Comment: comment, + Events: []DecodedCommonLog{ + {Signature: NO_DATA, EventData: map[string]interface{}{"warning": NO_DATA}}, + }, + }) } - return []*DecodedCall{} + return missedCalls } - func (t *Tracer) SaveDecodedCallsAsJson(dirname string) error { for txHash, calls := range t.GetAllDecodedCalls() { _, err := saveAsJson(calls, dirname, txHash) @@ -618,7 +645,8 @@ func (t *Tracer) decodeContractLogs(l zerolog.Logger, logs []TraceLog, a abi.ABI l.Trace().Str("Name", evSpec.RawName).Str("Signature", evSpec.Sig).Msg("Unpacking event") eventsMap, topicsMap, err := decodeEventFromLog(l, a, evSpec, lo) if err != nil { - return nil, errors.Wrap(err, ErrDecodeLog) + return nil, fmt.Errorf("failed to decode log for event '%s' (signature: %s): %w", + evSpec.RawName, evSpec.Sig, err) } parsedEvent := decodedLogFromMaps(&DecodedCommonLog{}, eventsMap, topicsMap) if decodedLog, ok := parsedEvent.(*DecodedCommonLog); ok { @@ -681,7 +709,14 @@ func (t *Tracer) printDecodedCallData(l zerolog.Logger, calls []*DecodedCall, re l.Debug().Str(fmt.Sprintf("%s- Method signature", indentation), dc.Signature).Send() l.Debug().Str(fmt.Sprintf("%s- Method name", indentation), dc.Method).Send() l.Debug().Str(fmt.Sprintf("%s- Gas used/limit", indentation), fmt.Sprintf("%d/%d", dc.GasUsed, dc.GasLimit)).Send() - l.Debug().Str(fmt.Sprintf("%s- Gas left", indentation), fmt.Sprintf("%d", dc.GasLimit-dc.GasUsed)).Send() + + gasLeft := mustSafeInt64(dc.GasLimit) - mustSafeInt64(dc.GasUsed) + if gasLeft < 0 { + l.Debug().Str(fmt.Sprintf("%s- Gas left", indentation), fmt.Sprintf("%d (negative due to gas refunds or stipends)", gasLeft)).Send() + } else { + l.Debug().Str(fmt.Sprintf("%s- Gas left", indentation), fmt.Sprintf("%d", gasLeft)).Send() + } + if dc.Comment != "" { l.Debug().Str(fmt.Sprintf("%s- Comment", indentation), dc.Comment).Send() } diff --git a/seth/util.go b/seth/util.go index 5f9e8f33d..f0c0fc4da 100644 --- a/seth/util.go +++ b/seth/util.go @@ -5,6 +5,7 @@ import ( "database/sql/driver" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "math" @@ -18,7 +19,6 @@ import ( "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/params" - "github.com/pkg/errors" network_debug_contract "github.com/smartcontractkit/chainlink-testing-framework/seth/contracts/bind/NetworkDebugContract" network_sub_debug_contract "github.com/smartcontractkit/chainlink-testing-framework/seth/contracts/bind/NetworkDebugSubContract" @@ -83,7 +83,18 @@ func (m *Client) CalculateSubKeyFunding(addrs, gasPrice, rooKeyBuffer int64) (*F Msg("Root key balance") if freeBalance.Cmp(big.NewInt(0)) < 0 { - return nil, fmt.Errorf(ErrInsufficientRootKeyBalance, freeBalance.String()) + return nil, fmt.Errorf("insufficient root key balance.\n"+ + "Current balance: %s wei (%s ETH)\n"+ + "Required for operation: %s wei (%s ETH)\n"+ + "Deficit: %s wei (%s ETH)\n"+ + "Solutions:\n"+ + " 1. Fund the root key address with more ETH\n"+ + " 2. Reduce number of ephemeral keys (current: %d)\n"+ + " 3. Lower root_key_funds_buffer in config (current: %d ETH)", + balance.String(), WeiToEther(balance).Text('f', 6), + new(big.Int).Add(totalFee, rootKeyBuffer).String(), WeiToEther(new(big.Int).Add(totalFee, rootKeyBuffer)).Text('f', 6), + new(big.Int).Abs(freeBalance).String(), WeiToEther(new(big.Int).Abs(freeBalance)).Text('f', 6), + addrs, rooKeyBuffer) } addrFunding := new(big.Int).Div(freeBalance, big.NewInt(addrs)) @@ -96,7 +107,21 @@ func (m *Client) CalculateSubKeyFunding(addrs, gasPrice, rooKeyBuffer int64) (*F Msg("Using hardcoded ephemeral funding") if freeBalance.Cmp(requiredBalance) < 0 { - return nil, fmt.Errorf(ErrInsufficientRootKeyBalance, freeBalance.String()) + return nil, fmt.Errorf("insufficient root key balance for funding %d ephemeral keys.\n"+ + "Available balance: %s wei (%s ETH)\n"+ + "Required balance: %s wei (%s ETH)\n"+ + "Per-key funding: %s wei (%s ETH)\n"+ + "Solutions:\n"+ + " 1. Fund the root key with at least %s ETH\n"+ + " 2. Reduce ephemeral_addresses_number to %d or fewer in config\n"+ + " 3. Reduce root_key_funds_buffer (currently reserves %d ETH)", + addrs, + freeBalance.String(), WeiToEther(freeBalance).Text('f', 6), + requiredBalance.String(), WeiToEther(requiredBalance).Text('f', 6), + addrFunding.String(), WeiToEther(addrFunding).Text('f', 6), + WeiToEther(requiredBalance).Text('f', 6), + new(big.Int).Div(freeBalance, addrFunding).Int64(), + rooKeyBuffer) } bd := &FundingDetails{ @@ -161,7 +186,13 @@ type Duration struct{ D time.Duration } func MakeDuration(d time.Duration) (Duration, error) { if d < time.Duration(0) { - return Duration{}, fmt.Errorf("cannot make negative time duration: %s", d) + return Duration{}, fmt.Errorf("invalid negative duration: %s\n"+ + "Duration values must be non-negative.\n"+ + "Check your configuration values for:\n"+ + " - pending_transaction_timeout\n"+ + " - transaction_timeout\n"+ + " - Any other time.Duration config fields", + d) } return Duration{D: d}, nil } @@ -236,7 +267,7 @@ func (d *Duration) Scan(v interface{}) (err error) { *d, err = MakeDuration(time.Duration(tv)) return err default: - return errors.Errorf(`don't know how to parse "%s" of type %T as a `+ + return fmt.Errorf(`don't know how to parse "%s" of type %T as a `+ `models.Duration`, tv, tv) } }