From 4f6d55a1a5291f446256d530a6922eed70151e17 Mon Sep 17 00:00:00 2001 From: Terry Tata Date: Fri, 31 Jul 2026 03:38:08 -0700 Subject: [PATCH] feat(evm): accept a Chainlink node TOML config in the EVM accessor --- .../pkg/accessors/evm/clnode_config.go | 228 ++++++++++ .../pkg/accessors/evm/clnode_config_test.go | 404 ++++++++++++++++++ .../pkg/accessors/evm/factory_constructor.go | 78 +++- .../accessors/evm/factory_constructor_test.go | 93 +++- 4 files changed, 791 insertions(+), 12 deletions(-) create mode 100644 integration/pkg/accessors/evm/clnode_config.go create mode 100644 integration/pkg/accessors/evm/clnode_config_test.go diff --git a/integration/pkg/accessors/evm/clnode_config.go b/integration/pkg/accessors/evm/clnode_config.go new file mode 100644 index 000000000..eef3a75e8 --- /dev/null +++ b/integration/pkg/accessors/evm/clnode_config.go @@ -0,0 +1,228 @@ +// Conversion of a Chainlink node's EVM configuration into the standalone operator config. A node +// operator moving off CL mode mounts the config file their node already runs with; loadConfig +// detects it and converts it here, so the endpoints and finality behavior carry over without anyone +// hand-writing a second file. +package evm + +import ( + "fmt" + "net/url" + "strconv" + "strings" + "time" + + "github.com/pelletier/go-toml/v2" + + chainsel "github.com/smartcontractkit/chain-selectors" + commonconfig "github.com/smartcontractkit/chainlink-common/pkg/config" + evmtoml "github.com/smartcontractkit/chainlink-evm/pkg/config/toml" +) + +// Conversion is the converted config together with everything the conversion dropped or decided. +// Warnings are not failures: they are the settings a Chainlink node accepts that standalone CCV has +// no equivalent for. CreateEVMAccessorFactory logs them at warn on startup, so an operator who +// mounted their node's file sees what changed rather than finding out from behavior. +// +// Warnings are ordered as the operator wrote the config: by chain, then by node within the chain, +// then by setting. A converted config with no warnings is normal and does not mean no conversion +// happened, which is why loadConfig reports the conversion itself rather than the warning count. +type Conversion struct { + Config Config + Warnings []string +} + +// nodeConfigFile is the sliver of a Chainlink node's TOML that this conversion reads. Every other +// section — Log, WebServer, P2P, Database — is ignored, so a whole node config can be passed in +// as-is. +type nodeConfigFile struct { + EVM evmtoml.EVMConfigs `toml:"EVM"` +} + +// convertChainlinkNodeConfig reads a Chainlink node TOML configuration and produces the standalone EVM operator +// config. Chain IDs are resolved to chain selectors, so a chain the node runs that CCV has no +// selector for is an error rather than a silently missing chain. +func convertChainlinkNodeConfig(nodeTOML []byte) (Conversion, error) { + var parsed nodeConfigFile + if err := toml.Unmarshal(nodeTOML, &parsed); err != nil { + return Conversion{}, fmt.Errorf("failed to parse Chainlink node config: %w", err) + } + if len(parsed.EVM) == 0 { + return Conversion{}, fmt.Errorf("config has no [[EVM]] sections: pass the node's TOML config, " + + "the one holding [[EVM]] and [[EVM.Nodes]]") + } + + merged, err := mergeByChainID(parsed.EVM) + if err != nil { + return Conversion{}, err + } + + var warnings []string + chains := make(map[string]ChainConfig, len(merged)) + for _, cfg := range merged { + chainID := cfg.ChainID.String() + if !cfg.IsEnabled() { + warnings = append(warnings, fmt.Sprintf("chain %s: skipped, the node has it disabled", chainID)) + continue + } + + details, err := chainsel.GetChainDetailsByChainIDAndFamily(chainID, chainsel.FamilyEVM) + if err != nil { + return Conversion{}, fmt.Errorf("chain %s has no known chain selector: %w", chainID, err) + } + + nodes, nodeWarnings, err := convertNodes(chainID, cfg.Nodes) + if err != nil { + return Conversion{}, err + } + warnings = append(warnings, nodeWarnings...) + + finalityDepth, err := convertFinality(cfg) + if err != nil { + return Conversion{}, err + } + + chains[strconv.FormatUint(details.ChainSelector, 10)] = ChainConfig{ + Nodes: nodes, + FinalityDepth: finalityDepth, + TXMBlockTime: txmBlockTimeOverride(cfg), + } + } + + if len(chains) == 0 { + return Conversion{}, fmt.Errorf("config declares no enabled EVM chains") + } + return Conversion{Config: Config{Chains: chains}, Warnings: warnings}, nil +} + +// mergeByChainID collapses repeated [[EVM]] blocks for the same chain, later blocks overriding +// earlier ones, which is how a Chainlink node itself reads them. Order of first appearance is +// preserved so warnings come out in the order the operator wrote their config. +func mergeByChainID(configs evmtoml.EVMConfigs) ([]*evmtoml.EVMConfig, error) { + var order []string + byID := make(map[string]*evmtoml.EVMConfig, len(configs)) + for i, cfg := range configs { + if cfg == nil { + continue + } + if cfg.ChainID == nil || cfg.ChainID.String() == "" { + return nil, fmt.Errorf("[[EVM]] entry %d has no ChainID", i) + } + id := cfg.ChainID.String() + if existing, ok := byID[id]; ok { + existing.SetFrom(cfg) + continue + } + byID[id] = cfg + order = append(order, id) + } + + out := make([]*evmtoml.EVMConfig, 0, len(order)) + for _, id := range order { + out = append(out, byID[id]) + } + return out, nil +} + +// convertNodes maps the node's RPC endpoints onto CCV's narrower node type. CCV models one HTTP +// endpoint and an optional WebSocket endpoint per node and nothing else, so anything a Chainlink +// node can express beyond that is dropped with a warning rather than approximated. +func convertNodes(chainID string, nodes evmtoml.EVMNodes) ([]Node, []string, error) { + if len(nodes) == 0 { + return nil, nil, fmt.Errorf("chain %s has no [[EVM.Nodes]] entries", chainID) + } + + var warnings []string + converted := make([]Node, 0, len(nodes)) + for i, node := range nodes { + if node == nil { + continue + } + name := "" + if node.Name != nil { + name = *node.Name + } + label := name + if label == "" { + label = fmt.Sprintf("index %d", i) + } + + // A send-only node is broadcast-only on a Chainlink node. CCV has no such concept, and + // carrying it over as an ordinary node would make it eligible for reads and head tracking — + // which is exactly what the operator marked it unfit for. Dropping it is the safe reading. + if node.SendOnly != nil && *node.SendOnly { + warnings = append(warnings, fmt.Sprintf( + "chain %s node %s: dropped, SendOnly nodes have no standalone equivalent", chainID, label)) + continue + } + if node.HTTPURL == nil { + return nil, nil, fmt.Errorf( + "chain %s node %s has no HTTPURL; standalone CCV requires an HTTP endpoint for every node", + chainID, label) + } + + // A slice rather than a map so the warnings come out in a fixed order, which keeps them + // grouped per node in the order the operator wrote the nodes. + for _, dropped := range []struct { + setting string + isSet bool + }{ + {"HTTPURLExtraWrite", node.HTTPURLExtraWrite != nil}, + {"Order", node.Order != nil}, + {"IsLoadBalancedRPC", node.IsLoadBalancedRPC != nil}, + } { + if dropped.isSet { + warnings = append(warnings, fmt.Sprintf( + "chain %s node %s: dropped %s, standalone CCV does not expose it", chainID, label, dropped.setting)) + } + } + + converted = append(converted, Node{ + Name: name, + HTTPUrl: urlString(node.HTTPURL), + WSUrl: urlString(node.WSURL), + }) + } + + if len(converted) == 0 { + return nil, nil, fmt.Errorf("chain %s has no usable [[EVM.Nodes]] entries after conversion", chainID) + } + return converted, warnings, nil +} + +// convertFinality maps the node's finality settings onto CCV's single finality_depth field, where +// zero selects finality-tag mode and a positive value selects confirmation-depth mode. +// +// The node's own defaults are applied first. A chain whose default is confirmation-depth mode +// behaves that way even when the operator's file says nothing about finality, so reading only the +// explicit settings would quietly move that chain onto finality tags. +func convertFinality(cfg *evmtoml.EVMConfig) (uint32, error) { + effective := evmtoml.Defaults(cfg.ChainID, &cfg.Chain) + if effective.FinalityTagEnabled != nil && *effective.FinalityTagEnabled { + return 0, nil + } + if effective.FinalityDepth == nil || *effective.FinalityDepth == 0 { + return 0, fmt.Errorf( + "chain %s uses confirmation-depth finality but has no FinalityDepth; set FinalityDepth or FinalityTagEnabled", + cfg.ChainID.String()) + } + return *effective.FinalityDepth, nil +} + +// txmBlockTimeOverride returns the operator's explicit TXM v2 block time, or zero to take CCV's own +// default. Only an explicit value carries over: the node's default for this setting applies to a +// transaction manager the node may not even run, so inheriting it would be inventing a tuning +// decision the operator never made. +func txmBlockTimeOverride(cfg *evmtoml.EVMConfig) time.Duration { + blockTime := cfg.Transactions.TransactionManagerV2.BlockTime + if blockTime == nil { + return 0 + } + return blockTime.Duration() +} + +func urlString(u *commonconfig.URL) string { + if u == nil { + return "" + } + return strings.TrimSpace((*url.URL)(u).String()) +} diff --git a/integration/pkg/accessors/evm/clnode_config_test.go b/integration/pkg/accessors/evm/clnode_config_test.go new file mode 100644 index 000000000..fae14ac2f --- /dev/null +++ b/integration/pkg/accessors/evm/clnode_config_test.go @@ -0,0 +1,404 @@ +package evm + +import ( + "math/big" + "testing" + "time" + + "github.com/BurntSushi/toml" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-common/pkg/sqlutil" + evmtoml "github.com/smartcontractkit/chainlink-evm/pkg/config/toml" +) + +// Chain selectors for the chain IDs used below, so the expectations read as the operator's chain +// rather than as an opaque number. +const ( + sepoliaChainID = "11155111" + sepoliaSelector = "16015286601757825753" + arbSepChainID = "421614" + arbSepSelector = "3478487238524512106" +) + +func TestConvertChainlinkNodeConfig(t *testing.T) { + t.Parallel() + + t.Run("maps chains, nodes and endpoints", func(t *testing.T) { + t.Parallel() + got, err := convertChainlinkNodeConfig([]byte(` +[Log] +Level = 'info' + +[[EVM]] +ChainID = '` + sepoliaChainID + `' +FinalityTagEnabled = false +FinalityDepth = 22 + +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' +WSURL = 'wss://sepolia.example.com' + +[[EVM.Nodes]] +Name = 'backup' +HTTPURL = 'https://sepolia-backup.example.com' +`)) + require.NoError(t, err) + require.Len(t, got.Config.Chains, 1) + + chain := got.Config.Chains[sepoliaSelector] + assert.Equal(t, uint32(22), chain.FinalityDepth) + require.Len(t, chain.Nodes, 2) + assert.Equal(t, Node{ + Name: "primary", + HTTPUrl: "https://sepolia.example.com", + WSUrl: "wss://sepolia.example.com", + }, chain.Nodes[0]) + assert.Equal(t, Node{ + Name: "backup", + HTTPUrl: "https://sepolia-backup.example.com", + }, chain.Nodes[1], "a node without a WSURL converts to HTTP-only, not to an empty ws_url string") + assert.Empty(t, got.Warnings) + }) + + t.Run("converts several chains", func(t *testing.T) { + t.Parallel() + got, err := convertChainlinkNodeConfig([]byte(` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +[[EVM.Nodes]] +Name = 'sepolia' +HTTPURL = 'https://sepolia.example.com' + +[[EVM]] +ChainID = '` + arbSepChainID + `' +[[EVM.Nodes]] +Name = 'arb' +HTTPURL = 'https://arb.example.com' +`)) + require.NoError(t, err) + assert.Len(t, got.Config.Chains, 2) + assert.Contains(t, got.Config.Chains, sepoliaSelector) + assert.Contains(t, got.Config.Chains, arbSepSelector) + }) + + t.Run("finality tags convert to a zero depth", func(t *testing.T) { + t.Parallel() + got, err := convertChainlinkNodeConfig([]byte(` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +FinalityTagEnabled = true +FinalityDepth = 50 +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' +`)) + require.NoError(t, err) + assert.Equal(t, uint32(0), got.Config.Chains[sepoliaSelector].FinalityDepth, + "finality-tag mode is expressed as a zero depth, and the unused FinalityDepth must not leak through") + }) + + t.Run("an unset finality mode follows the chain default rather than assuming tags", func(t *testing.T) { + t.Parallel() + got, err := convertChainlinkNodeConfig([]byte(` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' +`)) + require.NoError(t, err) + // Whichever mode the node defaults Sepolia to, the converted config has to reproduce it. The + // bug this guards against is emitting a zero depth — finality-tag mode — for a chain the node + // actually runs on confirmation depth. + chain := got.Config.Chains[sepoliaSelector] + assert.Equal(t, sepoliaDefaultFinalityDepth(t), chain.FinalityDepth) + }) + + t.Run("carries an explicit TXM block time and leaves an unset one to the standalone default", func(t *testing.T) { + t.Parallel() + withOverride, err := convertChainlinkNodeConfig([]byte(` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +[EVM.Transactions.TransactionManagerV2] +BlockTime = '7s' +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' +`)) + require.NoError(t, err) + assert.Equal(t, 7*time.Second, withOverride.Config.Chains[sepoliaSelector].TXMBlockTime) + + without, err := convertChainlinkNodeConfig([]byte(` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' +`)) + require.NoError(t, err) + assert.Equal(t, time.Duration(0), without.Config.Chains[sepoliaSelector].TXMBlockTime) + }) + + t.Run("later blocks for the same chain override earlier ones", func(t *testing.T) { + t.Parallel() + got, err := convertChainlinkNodeConfig([]byte(` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +FinalityTagEnabled = false +FinalityDepth = 10 +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' + +[[EVM]] +ChainID = '` + sepoliaChainID + `' +FinalityDepth = 99 +`)) + require.NoError(t, err) + require.Len(t, got.Config.Chains, 1) + assert.Equal(t, uint32(99), got.Config.Chains[sepoliaSelector].FinalityDepth) + }) + + t.Run("drops send-only nodes with a warning", func(t *testing.T) { + t.Parallel() + got, err := convertChainlinkNodeConfig([]byte(` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' + +[[EVM.Nodes]] +Name = 'broadcast-only' +HTTPURL = 'https://sepolia-broadcast.example.com' +SendOnly = true +`)) + require.NoError(t, err) + require.Len(t, got.Config.Chains[sepoliaSelector].Nodes, 1) + assert.Equal(t, "primary", got.Config.Chains[sepoliaSelector].Nodes[0].Name) + require.Len(t, got.Warnings, 1) + assert.Contains(t, got.Warnings[0], "broadcast-only") + assert.Contains(t, got.Warnings[0], "SendOnly") + }) + + t.Run("warns about settings with no standalone equivalent", func(t *testing.T) { + t.Parallel() + got, err := convertChainlinkNodeConfig([]byte(` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' +Order = 50 +`)) + require.NoError(t, err) + require.Len(t, got.Warnings, 1) + assert.Contains(t, got.Warnings[0], "Order") + assert.Len(t, got.Config.Chains[sepoliaSelector].Nodes, 1) + }) + + t.Run("skips a disabled chain with a warning", func(t *testing.T) { + t.Parallel() + got, err := convertChainlinkNodeConfig([]byte(` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +Enabled = false +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' + +[[EVM]] +ChainID = '` + arbSepChainID + `' +[[EVM.Nodes]] +Name = 'arb' +HTTPURL = 'https://arb.example.com' +`)) + require.NoError(t, err) + assert.Len(t, got.Config.Chains, 1) + assert.Contains(t, got.Config.Chains, arbSepSelector) + require.Len(t, got.Warnings, 1) + assert.Contains(t, got.Warnings[0], "disabled") + }) +} + +// Warnings come out in the order the operator wrote the config, so an operator reading the startup +// log walks their own file top to bottom. Every pair below is deliberately in the opposite order to +// what sorting the strings would produce: arbitrum-sepolia precedes sepolia though "11155111" sorts +// before "421614", node zeta precedes node alpha, and "dropped Order" precedes +// "dropped IsLoadBalancedRPC". +func TestConvertChainlinkNodeConfigWarningsFollowOperatorOrder(t *testing.T) { + t.Parallel() + + got, err := convertChainlinkNodeConfig([]byte(` +[[EVM]] +ChainID = '` + arbSepChainID + `' +[[EVM.Nodes]] +Name = 'zeta' +HTTPURL = 'https://arb-zeta.example.com' +Order = 50 +IsLoadBalancedRPC = true + +[[EVM.Nodes]] +Name = 'alpha' +HTTPURL = 'https://arb-alpha.example.com' +SendOnly = true + +[[EVM]] +ChainID = '` + sepoliaChainID + `' +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' +HTTPURLExtraWrite = 'https://sepolia-write.example.com' +`)) + require.NoError(t, err) + + require.Equal(t, []string{ + "chain " + arbSepChainID + " node zeta: dropped Order, standalone CCV does not expose it", + "chain " + arbSepChainID + " node zeta: dropped IsLoadBalancedRPC, standalone CCV does not expose it", + "chain " + arbSepChainID + " node alpha: dropped, SendOnly nodes have no standalone equivalent", + "chain " + sepoliaChainID + " node primary: dropped HTTPURLExtraWrite, standalone CCV does not expose it", + }, got.Warnings) +} + +// TestConvertedConfigLoadsStrictly encodes a conversion the way the CLI does and decodes it the way +// a standalone container does. The accessor's loader rejects any field it does not recognize, so a +// conversion that emitted a key the mounted config has no home for would take the node down at +// startup rather than showing up here — this closes that loop in a unit test. +func TestConvertedConfigLoadsStrictly(t *testing.T) { + t.Parallel() + + converted, err := convertChainlinkNodeConfig([]byte(` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +FinalityTagEnabled = false +FinalityDepth = 15 +[EVM.Transactions.TransactionManagerV2] +BlockTime = '3s' +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' +WSURL = 'wss://sepolia.example.com' +[[EVM.Nodes]] +Name = 'backup' +HTTPURL = 'https://sepolia-backup.example.com' +`)) + require.NoError(t, err) + + encoded, err := toml.Marshal(converted.Config) + require.NoError(t, err) + t.Logf("converted config:\n%s", encoded) + + var reloaded Config + md, err := toml.Decode(string(encoded), &reloaded) + require.NoError(t, err) + require.Empty(t, md.Undecoded(), "the standalone loader rejects unknown fields") + assert.Equal(t, converted.Config, reloaded, "the config must survive the encode/decode the CLI and container perform") +} + +func TestConvertChainlinkNodeConfigErrors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config string + wantErr string + }{ + { + name: "no EVM sections", + config: "[Log]\nLevel = 'info'\n", + wantErr: "no [[EVM]] sections", + }, + { + name: "missing chain ID", + config: "[[EVM]]\nFinalityDepth = 5\n", + wantErr: "no ChainID", + }, + { + name: "unknown chain ID", + config: ` +[[EVM]] +ChainID = '88888888888888' +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://example.com' +`, + wantErr: "no known chain selector", + }, + { + name: "chain with no nodes", + config: "[[EVM]]\nChainID = '" + sepoliaChainID + "'\n", + wantErr: "no [[EVM.Nodes]] entries", + }, + { + name: "node without an HTTP URL", + config: ` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +[[EVM.Nodes]] +Name = 'ws-only' +WSURL = 'wss://sepolia.example.com' +`, + wantErr: "no HTTPURL", + }, + { + name: "every node dropped", + config: ` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +[[EVM.Nodes]] +Name = 'broadcast-only' +HTTPURL = 'https://sepolia.example.com' +SendOnly = true +`, + wantErr: "no usable [[EVM.Nodes]] entries", + }, + { + name: "all chains disabled", + config: ` +[[EVM]] +ChainID = '` + sepoliaChainID + `' +Enabled = false +[[EVM.Nodes]] +Name = 'primary' +HTTPURL = 'https://sepolia.example.com' +`, + wantErr: "no enabled EVM chains", + }, + { + name: "malformed TOML", + config: "[[EVM]\nChainID = 'x'\n", + wantErr: "failed to parse", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := convertChainlinkNodeConfig([]byte(tt.config)) + require.ErrorContains(t, err, tt.wantErr) + }) + } +} + +// sepoliaDefaultFinalityDepth is the depth the conversion must emit for a Sepolia chain whose +// finality settings the operator left unset: zero if the node defaults Sepolia to finality tags, +// the default confirmation depth otherwise. It is read from chainlink-evm's own defaults rather +// than hard-coded, so a dependency bump that changes them moves the expectation with the behavior. +// +// The bug it guards against is a conversion that reads only the explicitly set fields: that would +// emit a zero depth — finality-tag mode — for every chain the operator did not configure, silently +// moving a confirmation-depth chain onto tags. +func sepoliaDefaultFinalityDepth(t *testing.T) uint32 { + t.Helper() + defaults := evmtoml.Defaults(sqlutil.New(big.NewInt(11155111))) + if defaults.FinalityTagEnabled != nil && *defaults.FinalityTagEnabled { + t.Log("chainlink-evm defaults Sepolia to finality tags; expecting a zero depth") + return 0 + } + require.NotNil(t, defaults.FinalityDepth, "a chain not on finality tags must have a default depth") + t.Logf("chainlink-evm defaults Sepolia to confirmation depth %d", *defaults.FinalityDepth) + return *defaults.FinalityDepth +} diff --git a/integration/pkg/accessors/evm/factory_constructor.go b/integration/pkg/accessors/evm/factory_constructor.go index 58cec42e5..ab6860329 100644 --- a/integration/pkg/accessors/evm/factory_constructor.go +++ b/integration/pkg/accessors/evm/factory_constructor.go @@ -20,17 +20,74 @@ func init() { var _ chainaccess.AccessorFactoryConstructor = CreateEVMAccessorFactory -func loadConfig(path string) (*Config, error) { +// loadConfig reads the mounted EVM config, accepting either the standalone format or a Chainlink +// node's own TOML. +// +// Accepting the node's file directly is what keeps the CL-to-standalone migration free of a +// conversion step: an operator mounts the config their node already runs with and starts the +// process. Settings standalone CCV has no equivalent for are dropped, and the conversion's warnings +// say which, so nothing goes missing silently. +// +// The two formats are told apart by their top-level table: `chains` is the standalone format, +// `EVM` is a node config. Anything with neither is rejected by the strict decode below. +// +// The second return is the conversion, or nil when the file was already in the standalone format. +// Whether a conversion happened cannot be inferred from the warnings, since a node config that +// converts cleanly produces none. +func loadConfig(path string) (*Config, *Conversion, error) { + data, err := os.ReadFile(path) //nolint:gosec // G304: operator-provided config path + if err != nil { + return nil, nil, fmt.Errorf("failed to read config file %s: %w", path, err) + } + + isNodeConfig, err := isChainlinkNodeConfig(data) + if err != nil { + return nil, nil, fmt.Errorf("failed to inspect config file %s: %w", path, err) + } + if isNodeConfig { + conversion, cerr := convertChainlinkNodeConfig(data) + if cerr != nil { + return nil, nil, fmt.Errorf("failed to convert Chainlink node config %s: %w", path, cerr) + } + return &conversion.Config, &conversion, nil + } + var cfg Config - md, err := toml.DecodeFile(path, &cfg) + md, err := toml.Decode(string(data), &cfg) if err != nil { - return nil, fmt.Errorf("failed to unmarshal config file %s: %w", path, err) + return nil, nil, fmt.Errorf("failed to unmarshal config file %s: %w", path, err) } if len(md.Undecoded()) > 0 { - return nil, fmt.Errorf("unknown fields in config: %v", md.Undecoded()) + return nil, nil, fmt.Errorf("unknown fields in config: %v", md.Undecoded()) } - return &cfg, nil + return &cfg, nil, nil +} + +// isChainlinkNodeConfig reports whether the file carries a Chainlink node's EVM sections rather than +// the standalone `chains` table. +// +// The test is presence of the top-level EVM key, not whether it holds any chains. A file with an +// empty or malformed EVM key is a node config the operator got wrong, and routing it to the +// converter produces an error that says so; treating it as a standalone config instead would report +// the node's own section as an unknown field. Decoding into Primitive defers the shape check, so the +// table and array forms both classify rather than failing here. +// +// A file with both top-level keys is neither — a concatenation accident the converter would +// otherwise "fix" by silently ignoring the standalone section — so it is rejected outright. +func isChainlinkNodeConfig(data []byte) (bool, error) { + var probe map[string]toml.Primitive + if _, err := toml.Decode(string(data), &probe); err != nil { + return false, err + } + _, hasEVM := probe["EVM"] + _, hasChains := probe["chains"] + if hasEVM && hasChains { + return false, fmt.Errorf( + "config has both a top-level 'EVM' table and a top-level 'chains' table: it is neither " + + "a Chainlink node config nor a standalone one — mount one, not a concatenation of both") + } + return hasEVM, nil } func resolveConfigPath() string { @@ -94,15 +151,22 @@ func (c Config) toInfos() (chainaccess.Infos[Info], error) { // very unusual for a config to have more than one of Committee/Token/Executor configs. func CreateEVMAccessorFactory(lggr logger.Logger, genericConfig chainaccess.GenericConfig) (chainaccess.AccessorFactory, error) { configPath := resolveConfigPath() - evmConfig, err := loadConfig(configPath) + evmConfig, conversion, err := loadConfig(configPath) if err != nil { return nil, fmt.Errorf("failed to load EVM config: %w", err) } + // Present only when a Chainlink node config was converted. Logged at warn so an operator who + // mounted their node's file sees what standalone CCV could not carry over. + if conversion != nil { + for _, warning := range conversion.Warnings { + lggr.Warnw("converted Chainlink node EVM config", "detail", warning) + } + } infos, err := evmConfig.toInfos() if err != nil { return nil, fmt.Errorf("failed to build EVM chain infos: %w", err) } - lggr.Infow("loaded EVM config", "numChains", len(infos)) + lggr.Infow("loaded EVM config", "numChains", len(infos), "convertedFromNodeConfig", conversion != nil) return CreateAccessorFactory(lggr, genericConfig, infos) } diff --git a/integration/pkg/accessors/evm/factory_constructor_test.go b/integration/pkg/accessors/evm/factory_constructor_test.go index 731091cca..dc13e9f70 100644 --- a/integration/pkg/accessors/evm/factory_constructor_test.go +++ b/integration/pkg/accessors/evm/factory_constructor_test.go @@ -32,7 +32,7 @@ func TestLoadConfig(t *testing.T) { require.NoError(t, err) require.NoError(t, os.WriteFile(path, data, 0o600)) - got, err := loadConfig(path) + got, _, err := loadConfig(path) require.NoError(t, err) require.Equal(t, want, *got) } @@ -48,12 +48,95 @@ internal_http_url = "http://evm-node:8545" external_ws_url = "wss://eth-mainnet.example.com" `), 0o600)) - _, err := loadConfig(path) + _, _, err := loadConfig(path) require.ErrorContains(t, err, "unknown fields in config") require.ErrorContains(t, err, "internal_http_url") require.ErrorContains(t, err, "external_ws_url") } +// A node config that converts cleanly produces no warnings, so the warning count cannot be used to +// tell a conversion from a standalone file. loadConfig reports the conversion itself. +func TestLoadConfigReportsConversionWithoutWarnings(t *testing.T) { + path := filepath.Join(t.TempDir(), "evm.toml") + require.NoError(t, os.WriteFile(path, []byte(` +[[EVM]] +ChainID = "1" +FinalityDepth = 42 +[[EVM.Nodes]] +Name = "chainstack" +HTTPURL = "http://evm-node:8545" +`), 0o600)) + + cfg, conversion, err := loadConfig(path) + require.NoError(t, err) + require.NotNil(t, conversion, "a converted node config must be reported as converted") + require.Empty(t, conversion.Warnings, "nothing in this config is dropped") + require.Contains(t, cfg.Chains, "5009297550715157269") +} + +func TestLoadConfigReportsNoConversionForStandaloneConfig(t *testing.T) { + path := filepath.Join(t.TempDir(), "evm.toml") + require.NoError(t, os.WriteFile(path, []byte(` +[chains.5009297550715157269] +finality_depth = 42 +[[chains.5009297550715157269.nodes]] +http_url = "http://evm-node:8545" +`), 0o600)) + + _, conversion, err := loadConfig(path) + require.NoError(t, err) + require.Nil(t, conversion, "a standalone config was not converted") +} + +// An EVM key with no usable chains is a node config the operator got wrong, not a standalone config. +// Classifying on the key's presence rather than its contents is what lets the converter say so; +// otherwise the node's own section comes back as an unknown field. +func TestLoadConfigClassifiesEmptyEVMKeyAsNodeConfig(t *testing.T) { + tests := []struct { + name string + body string + // Both cases must be reported as a problem with the node config rather than as an unknown + // field, which is all the classification can promise; the table form fails in the parser + // before the section count is ever reached. + wantErr string + }{ + { + name: "empty array", + body: "EVM = []\n", + wantErr: "config has no [[EVM]] sections", + }, + { + name: "table form", + body: "[EVM]\nChainID = \"1\"\n", + wantErr: "failed to convert Chainlink node config", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "evm.toml") + require.NoError(t, os.WriteFile(path, []byte(tt.body), 0o600)) + + _, _, err := loadConfig(path) + require.ErrorContains(t, err, tt.wantErr) + require.NotContains(t, err.Error(), "unknown fields in config", + "the node's own EVM section must not come back as an unknown field") + }) + } +} + +// A file carrying both top-level tables is a concatenation accident, not a format: the converter +// would otherwise silently ignore the standalone section, which is exactly the kind of drop the +// strict decode exists to prevent. +func TestLoadConfigRejectsAConfigWithBothFormats(t *testing.T) { + path := filepath.Join(t.TempDir(), "evm.toml") + body := "[chains.5009297550715157269]\n\n[[EVM]]\nChainID = \"1\"\n" + require.NoError(t, os.WriteFile(path, []byte(body), 0o600)) + + _, _, err := loadConfig(path) + require.ErrorContains(t, err, "both a top-level 'EVM' table and a top-level 'chains' table") +} + // The mounted config carries only operator-owned connection and runtime settings; // the accessor derives each chain's metadata from its selector at load time. func TestConfigToInfosDerivesChainMetadataFromSelector(t *testing.T) { @@ -86,7 +169,7 @@ func TestLoadConfigRejectsUnknownFields(t *testing.T) { path := filepath.Join(t.TempDir(), "evm.toml") require.NoError(t, os.WriteFile(path, []byte("unexpected = true\n"), 0o600)) - _, err := loadConfig(path) + _, _, err := loadConfig(path) require.ErrorContains(t, err, "unknown fields in config") } @@ -98,7 +181,7 @@ chain_type = "ethereum" unique_chain_name = "ethereum-mainnet" `), 0o600)) - _, err := loadConfig(path) + _, _, err := loadConfig(path) require.ErrorContains(t, err, "unknown fields in config") require.ErrorContains(t, err, "chain_type") require.ErrorContains(t, err, "unique_chain_name") @@ -113,7 +196,7 @@ http_url = "http://evm-node:8545" SendOnly = true `), 0o600)) - _, err := loadConfig(path) + _, _, err := loadConfig(path) require.ErrorContains(t, err, "unknown fields in config") require.ErrorContains(t, err, "SendOnly") }