From 52648b1643e45de4fd6915ec76eb8843f43632ec Mon Sep 17 00:00:00 2001 From: Graham Goh Date: Wed, 24 Dec 2025 15:20:54 +1100 Subject: [PATCH] feat: introducing lazy chain loading Chains are loaded lazily instead of eager, this means chains will only be loaded when it is being used, this means users are not forced to provide any secrets for chains if they are not used upfront and avoid loading huge amount of chains that are never used. This change also means we can deprecate `ChainOverrides` feature as we no longer have to tell CLD what chains to load. This lazy loading is hidden under the feature flag CLD_LAZY_BLOCKCHAINS --- .changeset/empty-words-tickle.md | 7 + chain/blockchain.go | 20 + chain/lazy_blockchains.go | 412 +++++++ chain/lazy_blockchains_test.go | 1104 +++++++++++++++++++ deployment/environment.go | 5 +- engine/cld/chains/chains.go | 66 +- engine/cld/environment/environment.go | 32 +- engine/cld/environment/environment_test.go | 35 + engine/cld/legacy/cli/mcmsv2/mcms_v2.go | 2 +- engine/test/environment/environment_test.go | 18 +- experimental/analyzer/evm_analyzer_test.go | 56 +- 11 files changed, 1706 insertions(+), 51 deletions(-) create mode 100644 .changeset/empty-words-tickle.md create mode 100644 chain/lazy_blockchains.go create mode 100644 chain/lazy_blockchains_test.go diff --git a/.changeset/empty-words-tickle.md b/.changeset/empty-words-tickle.md new file mode 100644 index 00000000..962f89bc --- /dev/null +++ b/.changeset/empty-words-tickle.md @@ -0,0 +1,7 @@ +--- +"chainlink-deployments-framework": minor +--- + +feat(chain): introduce lazy chain loading + +feature toggle under CLD_LAZY_BLOCKCHAINS environment variable to enable lazy loading of chains. diff --git a/chain/blockchain.go b/chain/blockchain.go index e80aac9b..7a592a85 100644 --- a/chain/blockchain.go +++ b/chain/blockchain.go @@ -24,6 +24,10 @@ var _ BlockChain = sui.Chain{} var _ BlockChain = ton.Chain{} var _ BlockChain = tron.Chain{} +// Compile-time checks that both BlockChains and LazyBlockChains implement BlockChainCollection +var _ BlockChainCollection = BlockChains{} +var _ BlockChainCollection = (*LazyBlockChains)(nil) + // BlockChain is an interface that represents a chain. // A chain can be an EVM chain, Solana chain Aptos chain or others. type BlockChain interface { @@ -35,6 +39,22 @@ type BlockChain interface { Family() string } +// BlockChainCollection defines the common interface for accessing blockchain instances. +// Both BlockChains and LazyBlockChains implement this interface. +type BlockChainCollection interface { + GetBySelector(selector uint64) (BlockChain, error) + Exists(selector uint64) bool + ExistsN(selectors ...uint64) bool + All() iter.Seq2[uint64, BlockChain] + EVMChains() map[uint64]evm.Chain + SolanaChains() map[uint64]solana.Chain + AptosChains() map[uint64]aptos.Chain + SuiChains() map[uint64]sui.Chain + TonChains() map[uint64]ton.Chain + TronChains() map[uint64]tron.Chain + ListChainSelectors(options ...ChainSelectorsOption) []uint64 +} + // BlockChains represents a collection of chains. // It provides querying capabilities for different types of chains. type BlockChains struct { diff --git a/chain/lazy_blockchains.go b/chain/lazy_blockchains.go new file mode 100644 index 00000000..0f563cb8 --- /dev/null +++ b/chain/lazy_blockchains.go @@ -0,0 +1,412 @@ +package chain + +import ( + "context" + "errors" + "fmt" + "iter" + "maps" + "slices" + "sync" + + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain/aptos" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/evm" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/solana" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/sui" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/ton" + "github.com/smartcontractkit/chainlink-deployments-framework/chain/tron" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +// ChainLoader is an interface for loading a blockchain instance lazily. +type ChainLoader interface { + Load(ctx context.Context, selector uint64) (BlockChain, error) +} + +// LazyBlockChains is a thread-safe wrapper around BlockChains that loads chains on-demand. +// It maintains a cache of loaded chains and uses ChainLoaders to initialize chains when first accessed. +type LazyBlockChains struct { + mu sync.RWMutex + loadedChains map[uint64]BlockChain + loaders map[string]ChainLoader // keyed by chain family + supportedSelectors map[uint64]string // maps selector to chain family + ctx context.Context //nolint:containedctx // Context is needed for lazy loading operations + lggr logger.Logger +} + +// NewLazyBlockChains creates a new LazyBlockChains instance. +// supportedSelectors maps chain selectors to their family (e.g., "evm", "solana", "aptos"). +// loaders provides the ChainLoader for each family. +// +// Chains are loaded on-demand when first accessed. If a chain fails to load during access +// (via GetBySelector, EVMChains, SolanaChains, etc.), the error is logged using lggr and +// the failing chain is skipped. This ensures graceful degradation - successfully loaded +// chains remain accessible while failures are visible in logs. +func NewLazyBlockChains( + ctx context.Context, + supportedSelectors map[uint64]string, + loaders map[string]ChainLoader, + lggr logger.Logger, +) *LazyBlockChains { + return &LazyBlockChains{ + loadedChains: make(map[uint64]BlockChain), + loaders: loaders, + supportedSelectors: supportedSelectors, + ctx: ctx, + lggr: lggr, + } +} + +// GetBySelector returns a blockchain by its selector, loading it lazily if not already loaded. +func (l *LazyBlockChains) GetBySelector(selector uint64) (BlockChain, error) { + // Fast path: check if already loaded + l.mu.RLock() + if chain, ok := l.loadedChains[selector]; ok { + l.mu.RUnlock() + return chain, nil + } + l.mu.RUnlock() + + // Slow path: need to load the chain + l.mu.Lock() + defer l.mu.Unlock() + + // Double-check after acquiring write lock + if chain, ok := l.loadedChains[selector]; ok { + return chain, nil + } + + // Check if the chain is available + family, ok := l.supportedSelectors[selector] + if !ok { + return nil, ErrBlockChainNotFound + } + + // Get the loader for this family + loader, ok := l.loaders[family] + if !ok { + return nil, ErrBlockChainNotFound + } + + // Load the chain + chain, err := loader.Load(l.ctx, selector) + if err != nil { + return nil, err + } + + // Cache the loaded chain + l.loadedChains[selector] = chain + + return chain, nil +} + +// Exists checks if a chain with the given selector is available (not necessarily loaded). +func (l *LazyBlockChains) Exists(selector uint64) bool { + _, ok := l.supportedSelectors[selector] + return ok +} + +// ExistsN checks if all chains with the given selectors are available. +func (l *LazyBlockChains) ExistsN(selectors ...uint64) bool { + for _, selector := range selectors { + if _, ok := l.supportedSelectors[selector]; !ok { + return false + } + } + + return true +} + +// All returns an iterator over all chains, loading them lazily as they are accessed. +// If a chain fails to load, the error is logged and the chain is skipped. +// +// Note: This method loads chains sequentially during iteration. For faster loading when +// iterating over all chains, consider converting to BlockChains first using ToBlockChains(), +// which loads all chains in parallel, then call All() on the result: +// +// blockChains, err := lazyChains.ToBlockChains() +// if err != nil { +// // handle error +// } +// for selector, chain := range blockChains.All() { +// // chains are already loaded +// } +func (l *LazyBlockChains) All() iter.Seq2[uint64, BlockChain] { + return func(yield func(uint64, BlockChain) bool) { + selectors := slices.Collect(maps.Keys(l.supportedSelectors)) + + // Sort for consistent iteration order + slices.Sort(selectors) + + for _, selector := range selectors { + chain, err := l.GetBySelector(selector) + if err != nil { + l.lggr.Errorw("Failed to load chain during iteration", + "selector", selector, + "error", err, + ) + // Skip chains that fail to load + continue + } + if !yield(selector, chain) { + return + } + } + } +} + +// EVMChains returns a map of all EVM chains, loading them lazily. +// If a chain fails to load, the error is logged and the chain is skipped. +func (l *LazyBlockChains) EVMChains() map[uint64]evm.Chain { + chains, err := l.TryEVMChains() + if err != nil { + l.lggr.Errorw("Failed to load one or more EVM chains", "error", err) + } + + return chains +} + +// SolanaChains returns a map of all Solana chains, loading them lazily. +// If a chain fails to load, the error is logged and the chain is skipped. +func (l *LazyBlockChains) SolanaChains() map[uint64]solana.Chain { + chains, err := l.TrySolanaChains() + if err != nil { + l.lggr.Errorw("Failed to load one or more Solana chains", "error", err) + } + + return chains +} + +// AptosChains returns a map of all Aptos chains, loading them lazily. +// If a chain fails to load, the error is logged and the chain is skipped. +func (l *LazyBlockChains) AptosChains() map[uint64]aptos.Chain { + chains, err := l.TryAptosChains() + if err != nil { + l.lggr.Errorw("Failed to load one or more Aptos chains", "error", err) + } + + return chains +} + +// SuiChains returns a map of all Sui chains, loading them lazily. +// If a chain fails to load, the error is logged and the chain is skipped. +func (l *LazyBlockChains) SuiChains() map[uint64]sui.Chain { + chains, err := l.TrySuiChains() + if err != nil { + l.lggr.Errorw("Failed to load one or more Sui chains", "error", err) + } + + return chains +} + +// TonChains returns a map of all Ton chains, loading them lazily. +// If a chain fails to load, the error is logged and the chain is skipped. +func (l *LazyBlockChains) TonChains() map[uint64]ton.Chain { + chains, err := l.TryTonChains() + if err != nil { + l.lggr.Errorw("Failed to load one or more Ton chains", "error", err) + } + + return chains +} + +// TronChains returns a map of all Tron chains, loading them lazily. +// If a chain fails to load, the error is logged and the chain is skipped. +func (l *LazyBlockChains) TronChains() map[uint64]tron.Chain { + chains, err := l.TryTronChains() + if err != nil { + l.lggr.Errorw("Failed to load one or more Tron chains", "error", err) + } + + return chains +} + +// TryEVMChains attempts to load all EVM chains and returns any errors encountered. +// Unlike EVMChains, this method returns an error if any chain fails to load. +// The error may contain multiple chain load failures wrapped together. +// Successfully loaded chains are still returned in the map. +func (l *LazyBlockChains) TryEVMChains() (map[uint64]evm.Chain, error) { + return tryChains[evm.Chain](l, chainsel.FamilyEVM) +} + +// TrySolanaChains attempts to load all Solana chains and returns any errors encountered. +// Unlike SolanaChains, this method returns an error if any chain fails to load. +// The error may contain multiple chain load failures wrapped together. +// Successfully loaded chains are still returned in the map. +func (l *LazyBlockChains) TrySolanaChains() (map[uint64]solana.Chain, error) { + return tryChains[solana.Chain](l, chainsel.FamilySolana) +} + +// TryAptosChains attempts to load all Aptos chains and returns any errors encountered. +// Unlike AptosChains, this method returns an error if any chain fails to load. +// The error may contain multiple chain load failures wrapped together. +// Successfully loaded chains are still returned in the map. +func (l *LazyBlockChains) TryAptosChains() (map[uint64]aptos.Chain, error) { + return tryChains[aptos.Chain](l, chainsel.FamilyAptos) +} + +// TrySuiChains attempts to load all Sui chains and returns any errors encountered. +// Unlike SuiChains, this method returns an error if any chain fails to load. +// The error may contain multiple chain load failures wrapped together. +// Successfully loaded chains are still returned in the map. +func (l *LazyBlockChains) TrySuiChains() (map[uint64]sui.Chain, error) { + return tryChains[sui.Chain](l, chainsel.FamilySui) +} + +// TryTonChains attempts to load all Ton chains and returns any errors encountered. +// Unlike TonChains, this method returns an error if any chain fails to load. +// The error may contain multiple chain load failures wrapped together. +// Successfully loaded chains are still returned in the map. +func (l *LazyBlockChains) TryTonChains() (map[uint64]ton.Chain, error) { + return tryChains[ton.Chain](l, chainsel.FamilyTon) +} + +// TryTronChains attempts to load all Tron chains and returns any errors encountered. +// Unlike TronChains, this method returns an error if any chain fails to load. +// The error may contain multiple chain load failures wrapped together. +// Successfully loaded chains are still returned in the map. +func (l *LazyBlockChains) TryTronChains() (map[uint64]tron.Chain, error) { + return tryChains[tron.Chain](l, chainsel.FamilyTron) +} + +// ListChainSelectors returns all available chain selectors with optional filtering. +func (l *LazyBlockChains) ListChainSelectors(options ...ChainSelectorsOption) []uint64 { + opts := chainSelectorsOptions{} + for _, option := range options { + option(&opts) + } + + selectors := make([]uint64, 0, len(l.supportedSelectors)) + for selector, family := range l.supportedSelectors { + if opts.excludedChainSels != nil { + if _, excluded := opts.excludedChainSels[selector]; excluded { + continue + } + } + if opts.includedFamilies != nil { + if _, ok := opts.includedFamilies[family]; !ok { + continue + } + } + selectors = append(selectors, selector) + } + + slices.Sort(selectors) + + return selectors +} + +// ToBlockChains converts the LazyBlockChains to a regular BlockChains instance. +// This loads all available chains eagerly. +func (l *LazyBlockChains) ToBlockChains() (BlockChains, error) { + selectors := make([]uint64, 0, len(l.supportedSelectors)) + for selector := range l.supportedSelectors { + selectors = append(selectors, selector) + } + + if len(selectors) == 0 { + return NewBlockChains(make(map[uint64]BlockChain)), nil + } + + // Load chains in parallel using helper + results := l.loadChainsParallel(selectors) + + // Collect results + chains := make(map[uint64]BlockChain) + for res := range results { + if res.err != nil { + return BlockChains{}, fmt.Errorf("failed to load chain %d: %w", res.selector, res.err) + } + chains[res.selector] = res.chain + } + + return NewBlockChains(chains), nil +} + +// tryChains is a generic function that attempts to load all chains of a specific family in parallel. +// It returns a map of successfully loaded chains and an error containing all failures. +// Type parameters: +// - T: the chain type (e.g., evm.Chain, solana.Chain) +// - PT: pointer to the chain type (e.g., *evm.Chain) +func tryChains[T any, PT interface { + *T +}](l *LazyBlockChains, family string) (map[uint64]T, error) { + // Get all selectors for this chain family + selectors := make([]uint64, 0) + for selector, f := range l.supportedSelectors { + if f == family { + selectors = append(selectors, selector) + } + } + + if len(selectors) == 0 { + return make(map[uint64]T), nil + } + + // Load chains in parallel using helper + results := l.loadChainsParallel(selectors) + + // Collect results + chains := make(map[uint64]T) + var errs []error + + for res := range results { + if res.err != nil { + errs = append(errs, fmt.Errorf("failed to load %s chain %d: %w", family, res.selector, res.err)) + continue + } + + // Type assertion to convert BlockChain to the specific chain type + switch c := res.chain.(type) { + case T: + chains[res.selector] = c + case PT: + if c != nil { + chains[res.selector] = *c + } + } + } + + if len(errs) > 0 { + return chains, errors.Join(errs...) + } + + return chains, nil +} + +// chainLoadResult represents the result of loading a single chain. +type chainLoadResult struct { + selector uint64 + chain BlockChain + err error +} + +// loadChainsParallel loads multiple chains in parallel and returns a channel of results. +// The channel is closed when all chains have been loaded. +func (l *LazyBlockChains) loadChainsParallel(selectors []uint64) <-chan chainLoadResult { + results := make(chan chainLoadResult, len(selectors)) + var wg sync.WaitGroup + + for _, selector := range selectors { + wg.Add(1) + go func(sel uint64) { + defer wg.Done() + chain, err := l.GetBySelector(sel) + results <- chainLoadResult{ + selector: sel, + chain: chain, + err: err, + } + }(selector) + } + + // Close results channel when all goroutines are done + go func() { + wg.Wait() + close(results) + }() + + return results +} diff --git a/chain/lazy_blockchains_test.go b/chain/lazy_blockchains_test.go new file mode 100644 index 00000000..71c52e2f --- /dev/null +++ b/chain/lazy_blockchains_test.go @@ -0,0 +1,1104 @@ +package chain_test + +import ( + "context" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" + + chainsel "github.com/smartcontractkit/chain-selectors" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +// Mock chain loader for testing lazy loading +type mockChainLoader struct { + loadFunc func(selector uint64) (chain.BlockChain, error) + loadCalls []uint64 +} + +func (m *mockChainLoader) Load(ctx context.Context, selector uint64) (chain.BlockChain, error) { + m.loadCalls = append(m.loadCalls, selector) + return m.loadFunc(selector) +} + +func TestLazyBlockChains_GetBySelector(t *testing.T) { + t.Parallel() + + t.Run("loads chain on first access", func(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + if selector == evmChain1.Selector { + return evmChain1, nil + } + + return nil, chain.ErrBlockChainNotFound + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // First access should load the chain + got, err := lazyChains.GetBySelector(evmChain1.Selector) + require.NoError(t, err) + assert.Equal(t, evmChain1, got) + assert.Len(t, loader.loadCalls, 1, "chain should be loaded once") + + // Second access should use cache + got, err = lazyChains.GetBySelector(evmChain1.Selector) + require.NoError(t, err) + assert.Equal(t, evmChain1, got) + assert.Len(t, loader.loadCalls, 1, "chain should not be loaded again") + }) + + t.Run("returns error for unavailable chain", func(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return evmChain1, nil + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Accessing non-existent chain should return error + _, err := lazyChains.GetBySelector(99999999) + require.Error(t, err) + require.ErrorIs(t, err, chain.ErrBlockChainNotFound) + assert.Empty(t, loader.loadCalls, "loader should not be called for unavailable chains") + }) +} + +func TestLazyBlockChains_Exists(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return evmChain1, nil + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Should return true for available chain without loading + assert.True(t, lazyChains.Exists(evmChain1.Selector)) + assert.Empty(t, loader.loadCalls, "Exists should not load the chain") + + // Should return false for unavailable chain + assert.False(t, lazyChains.Exists(99999999)) +} + +func TestLazyBlockChains_EVMChains(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + switch selector { + case evmChain1.Selector: + return evmChain1, nil + case evmChain2.Selector: + return evmChain2, nil + default: + return nil, chain.ErrBlockChainNotFound + } + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + evmChain2.Selector: chainsel.FamilyEVM, + solanaChain1.Selector: chainsel.FamilySolana, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + chainsel.FamilySolana: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Get EVM chains should load only EVM chains + evmChains := lazyChains.EVMChains() + assert.Len(t, evmChains, 2, "should return 2 EVM chains") + assert.Contains(t, evmChains, evmChain1.Selector) + assert.Contains(t, evmChains, evmChain2.Selector) + + // Loader should be called for EVM chains only + assert.ElementsMatch(t, []uint64{evmChain1.Selector, evmChain2.Selector}, loader.loadCalls) +} + +func TestLazyBlockChains_All(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + switch selector { + case evmChain1.Selector: + return evmChain1, nil + case solanaChain1.Selector: + return solanaChain1, nil + default: + return nil, chain.ErrBlockChainNotFound + } + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + solanaChain1.Selector: chainsel.FamilySolana, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + chainsel.FamilySolana: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Iterate through all chains + count := 0 + for selector, c := range lazyChains.All() { + count++ + assert.NotNil(t, c) + assert.True(t, selector == evmChain1.Selector || selector == solanaChain1.Selector) + } + + assert.Equal(t, 2, count, "should iterate over 2 chains") + assert.Len(t, loader.loadCalls, 2, "should load all chains during iteration") +} + +func TestLazyBlockChains_ListChainSelectors(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return evmChain1, nil // Return a valid chain instead of nil, nil + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + evmChain2.Selector: chainsel.FamilyEVM, + solanaChain1.Selector: chainsel.FamilySolana, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + chainsel.FamilySolana: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // List all selectors + selectors := lazyChains.ListChainSelectors() + assert.Len(t, selectors, 3, "should list 3 selectors") + assert.Empty(t, loader.loadCalls, "ListChainSelectors should not load chains") + + // Filter by family + evmSelectors := lazyChains.ListChainSelectors(chain.WithFamily(chainsel.FamilyEVM)) + assert.Len(t, evmSelectors, 2, "should list 2 EVM selectors") + assert.ElementsMatch(t, []uint64{evmChain1.Selector, evmChain2.Selector}, evmSelectors) +} + +func TestLazyBlockChains_ToBlockChains(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + switch selector { + case evmChain1.Selector: + return evmChain1, nil + case solanaChain1.Selector: + return solanaChain1, nil + default: + return nil, chain.ErrBlockChainNotFound + } + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + solanaChain1.Selector: chainsel.FamilySolana, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + chainsel.FamilySolana: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Convert to regular BlockChains + blockChains, err := lazyChains.ToBlockChains() + require.NoError(t, err) + + // Should load all chains + assert.Len(t, loader.loadCalls, 2, "should load all chains") + + // Verify chains are accessible + got, err := blockChains.GetBySelector(evmChain1.Selector) + require.NoError(t, err) + assert.Equal(t, evmChain1, got) + + got, err = blockChains.GetBySelector(solanaChain1.Selector) + require.NoError(t, err) + assert.Equal(t, solanaChain1, got) +} + +func TestLazyBlockChains_ToBlockChains_WithError(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + if selector == evmChain1.Selector { + return evmChain1, nil + } + // Simulate load error for other chains + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + solanaChain1.Selector: chainsel.FamilySolana, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + chainsel.FamilySolana: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // ToBlockChains should fail if any chain fails to load + _, err := lazyChains.ToBlockChains() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load chain") +} + +func TestLazyBlockChains_EVMChains_LoadError(t *testing.T) { + t.Parallel() + + // Create a logger that we can check for error logs + lggr, logs := logger.TestObserved(t, zapcore.DebugLevel) + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + if selector == evmChain1.Selector { + return evmChain1, nil + } + // Simulate a load error for evmChain2 + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + evmChain2.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, lggr) + + // Get EVM chains - should get the successful one and skip the failed one + evmChains := lazyChains.EVMChains() + assert.Len(t, evmChains, 1, "should return only successfully loaded chains") + assert.Contains(t, evmChains, evmChain1.Selector) + assert.NotContains(t, evmChains, evmChain2.Selector) + + // Verify error was logged + assert.Equal(t, 1, logs.FilterMessage("Failed to load one or more EVM chains").Len(), "should log error for failed chain") +} + +func TestLazyBlockChains_SolanaChains(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + switch selector { + case solanaChain1.Selector: + return solanaChain1, nil + case evmChain1.Selector: + return evmChain1, nil + default: + return nil, chain.ErrBlockChainNotFound + } + }, + } + + availableChains := map[uint64]string{ + solanaChain1.Selector: chainsel.FamilySolana, + evmChain1.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilySolana: loader, + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Get Solana chains should load only Solana chains + solanaChains := lazyChains.SolanaChains() + assert.Len(t, solanaChains, 1, "should return 1 Solana chain") + assert.Contains(t, solanaChains, solanaChain1.Selector) + + // Loader should be called for Solana chain only + assert.ElementsMatch(t, []uint64{solanaChain1.Selector}, loader.loadCalls) +} + +func TestLazyBlockChains_SolanaChains_LoadError(t *testing.T) { + t.Parallel() + + lggr, logs := logger.TestObserved(t, zapcore.DebugLevel) + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + solanaChain1.Selector: chainsel.FamilySolana, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilySolana: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, lggr) + + // Get Solana chains - should return empty map and log error + solanaChains := lazyChains.SolanaChains() + assert.Empty(t, solanaChains, "should return empty map when load fails") + + // Verify error was logged + assert.Equal(t, 1, logs.FilterMessage("Failed to load one or more Solana chains").Len(), "should log error for failed chain") +} + +func TestLazyBlockChains_AptosChains(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + switch selector { + case aptosChain1.Selector: + return aptosChain1, nil + default: + return nil, chain.ErrBlockChainNotFound + } + }, + } + + availableChains := map[uint64]string{ + aptosChain1.Selector: chainsel.FamilyAptos, + evmChain1.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyAptos: loader, + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Get Aptos chains should load only Aptos chains + aptosChains := lazyChains.AptosChains() + assert.Len(t, aptosChains, 1, "should return 1 Aptos chain") + assert.Contains(t, aptosChains, aptosChain1.Selector) + + // Loader should be called for Aptos chain only + assert.ElementsMatch(t, []uint64{aptosChain1.Selector}, loader.loadCalls) +} + +func TestLazyBlockChains_AptosChains_LoadError(t *testing.T) { + t.Parallel() + + lggr, logs := logger.TestObserved(t, zapcore.DebugLevel) + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + aptosChain1.Selector: chainsel.FamilyAptos, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyAptos: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, lggr) + + // Get Aptos chains - should return empty map and log error + aptosChains := lazyChains.AptosChains() + assert.Empty(t, aptosChains, "should return empty map when load fails") + + // Verify error was logged + assert.Equal(t, 1, logs.FilterMessage("Failed to load one or more Aptos chains").Len(), "should log error for failed chain") +} + +func TestLazyBlockChains_SuiChains(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + switch selector { + case suiChain1.Selector: + return suiChain1, nil + default: + return nil, chain.ErrBlockChainNotFound + } + }, + } + + availableChains := map[uint64]string{ + suiChain1.Selector: chainsel.FamilySui, + evmChain1.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilySui: loader, + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Get Sui chains should load only Sui chains + suiChains := lazyChains.SuiChains() + assert.Len(t, suiChains, 1, "should return 1 Sui chain") + assert.Contains(t, suiChains, suiChain1.Selector) + + // Loader should be called for Sui chain only + assert.ElementsMatch(t, []uint64{suiChain1.Selector}, loader.loadCalls) +} + +func TestLazyBlockChains_SuiChains_LoadError(t *testing.T) { + t.Parallel() + + lggr, logs := logger.TestObserved(t, zapcore.DebugLevel) + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + suiChain1.Selector: chainsel.FamilySui, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilySui: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, lggr) + + // Get Sui chains - should return empty map and log error + suiChains := lazyChains.SuiChains() + assert.Empty(t, suiChains, "should return empty map when load fails") + + // Verify error was logged + assert.Equal(t, 1, logs.FilterMessage("Failed to load one or more Sui chains").Len(), "should log error for failed chain") +} + +func TestLazyBlockChains_TonChains(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + switch selector { + case tonChain1.Selector: + return tonChain1, nil + default: + return nil, chain.ErrBlockChainNotFound + } + }, + } + + availableChains := map[uint64]string{ + tonChain1.Selector: chainsel.FamilyTon, + evmChain1.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyTon: loader, + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Get Ton chains should load only Ton chains + tonChains := lazyChains.TonChains() + assert.Len(t, tonChains, 1, "should return 1 Ton chain") + assert.Contains(t, tonChains, tonChain1.Selector) + + // Loader should be called for Ton chain only + assert.ElementsMatch(t, []uint64{tonChain1.Selector}, loader.loadCalls) +} + +func TestLazyBlockChains_TonChains_LoadError(t *testing.T) { + t.Parallel() + + lggr, logs := logger.TestObserved(t, zapcore.DebugLevel) + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + tonChain1.Selector: chainsel.FamilyTon, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyTon: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, lggr) + + // Get Ton chains - should return empty map and log error + tonChains := lazyChains.TonChains() + assert.Empty(t, tonChains, "should return empty map when load fails") + + // Verify error was logged + assert.Equal(t, 1, logs.FilterMessage("Failed to load one or more Ton chains").Len(), "should log error for failed chain") +} + +func TestLazyBlockChains_TronChains(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + switch selector { + case tronChain1.Selector: + return tronChain1, nil + default: + return nil, chain.ErrBlockChainNotFound + } + }, + } + + availableChains := map[uint64]string{ + tronChain1.Selector: chainsel.FamilyTron, + evmChain1.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyTron: loader, + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Get Tron chains should load only Tron chains + tronChains := lazyChains.TronChains() + assert.Len(t, tronChains, 1, "should return 1 Tron chain") + assert.Contains(t, tronChains, tronChain1.Selector) + + // Loader should be called for Tron chain only + assert.ElementsMatch(t, []uint64{tronChain1.Selector}, loader.loadCalls) +} + +func TestLazyBlockChains_TronChains_LoadError(t *testing.T) { + t.Parallel() + + lggr, logs := logger.TestObserved(t, zapcore.DebugLevel) + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + tronChain1.Selector: chainsel.FamilyTron, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyTron: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, lggr) + + // Get Tron chains - should return empty map and log error + tronChains := lazyChains.TronChains() + assert.Empty(t, tronChains, "should return empty map when load fails") + + // Verify error was logged + assert.Equal(t, 1, logs.FilterMessage("Failed to load one or more Tron chains").Len(), "should log error for failed chain") +} + +func TestLazyBlockChains_All_LoadError(t *testing.T) { + t.Parallel() + + lggr, logs := logger.TestObserved(t, zapcore.DebugLevel) + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + if selector == evmChain1.Selector { + return evmChain1, nil + } + // Fail to load solana chain + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + solanaChain1.Selector: chainsel.FamilySolana, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + chainsel.FamilySolana: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, lggr) + + // Iterate through all chains - should skip the failed one + count := 0 + for selector, c := range lazyChains.All() { + count++ + assert.NotNil(t, c) + assert.Equal(t, evmChain1.Selector, selector) + } + + assert.Equal(t, 1, count, "should iterate over only successfully loaded chains") + + // Verify error was logged + assert.Equal(t, 1, logs.FilterMessage("Failed to load chain during iteration").Len(), "should log error for failed chain") +} + +func TestLazyBlockChains_TryEVMChains_Success(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + switch selector { + case evmChain1.Selector: + return evmChain1, nil + case evmChain2.Selector: + return evmChain2, nil + default: + return nil, chain.ErrBlockChainNotFound + } + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + evmChain2.Selector: chainsel.FamilyEVM, + solanaChain1.Selector: chainsel.FamilySolana, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + chainsel.FamilySolana: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get EVM chains - should succeed with no error + evmChains, err := lazyChains.TryEVMChains() + require.NoError(t, err) + assert.Len(t, evmChains, 2, "should return 2 EVM chains") + assert.Contains(t, evmChains, evmChain1.Selector) + assert.Contains(t, evmChains, evmChain2.Selector) +} + +func TestLazyBlockChains_TryEVMChains_PartialFailure(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + if selector == evmChain1.Selector { + return evmChain1, nil + } + // Fail to load evmChain2 + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + evmChain2.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get EVM chains - should return error but also successful chains + evmChains, err := lazyChains.TryEVMChains() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load evm chain") + assert.Contains(t, err.Error(), strconv.FormatUint(evmChain2.Selector, 10)) + + // Should still get the successfully loaded chain + assert.Len(t, evmChains, 1, "should return successfully loaded chains") + assert.Contains(t, evmChains, evmChain1.Selector) +} + +func TestLazyBlockChains_TryEVMChains_AllFail(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + evmChain2.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get EVM chains - should return error with empty map + evmChains, err := lazyChains.TryEVMChains() + require.Error(t, err) + + // Error should contain both chain selectors + assert.Contains(t, err.Error(), strconv.FormatUint(evmChain1.Selector, 10)) + assert.Contains(t, err.Error(), strconv.FormatUint(evmChain2.Selector, 10)) + + assert.Empty(t, evmChains, "should return empty map when all fail") +} + +func TestLazyBlockChains_TrySolanaChains_Success(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + if selector == solanaChain1.Selector { + return solanaChain1, nil + } + + return nil, chain.ErrBlockChainNotFound + }, + } + + availableChains := map[uint64]string{ + solanaChain1.Selector: chainsel.FamilySolana, + evmChain1.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilySolana: loader, + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get Solana chains - should succeed + solanaChains, err := lazyChains.TrySolanaChains() + require.NoError(t, err) + assert.Len(t, solanaChains, 1) + assert.Contains(t, solanaChains, solanaChain1.Selector) +} + +func TestLazyBlockChains_TrySolanaChains_Failure(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + solanaChain1.Selector: chainsel.FamilySolana, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilySolana: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get Solana chains - should return error + solanaChains, err := lazyChains.TrySolanaChains() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load solana chain") + assert.Empty(t, solanaChains) +} + +func TestLazyBlockChains_TryAptosChains_Success(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + if selector == aptosChain1.Selector { + return aptosChain1, nil + } + + return nil, chain.ErrBlockChainNotFound + }, + } + + availableChains := map[uint64]string{ + aptosChain1.Selector: chainsel.FamilyAptos, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyAptos: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get Aptos chains - should succeed + aptosChains, err := lazyChains.TryAptosChains() + require.NoError(t, err) + assert.Len(t, aptosChains, 1) + assert.Contains(t, aptosChains, aptosChain1.Selector) +} + +func TestLazyBlockChains_TryAptosChains_Failure(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + aptosChain1.Selector: chainsel.FamilyAptos, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyAptos: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get Aptos chains - should return error + aptosChains, err := lazyChains.TryAptosChains() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load aptos chain") + assert.Empty(t, aptosChains) +} + +func TestLazyBlockChains_TrySuiChains_Success(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + if selector == suiChain1.Selector { + return suiChain1, nil + } + + return nil, chain.ErrBlockChainNotFound + }, + } + + availableChains := map[uint64]string{ + suiChain1.Selector: chainsel.FamilySui, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilySui: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get Sui chains - should succeed + suiChains, err := lazyChains.TrySuiChains() + require.NoError(t, err) + assert.Len(t, suiChains, 1) + assert.Contains(t, suiChains, suiChain1.Selector) +} + +func TestLazyBlockChains_TrySuiChains_Failure(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + suiChain1.Selector: chainsel.FamilySui, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilySui: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get Sui chains - should return error + suiChains, err := lazyChains.TrySuiChains() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load sui chain") + assert.Empty(t, suiChains) +} + +func TestLazyBlockChains_TryTonChains_Success(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + if selector == tonChain1.Selector { + return tonChain1, nil + } + + return nil, chain.ErrBlockChainNotFound + }, + } + + availableChains := map[uint64]string{ + tonChain1.Selector: chainsel.FamilyTon, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyTon: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get Ton chains - should succeed + tonChains, err := lazyChains.TryTonChains() + require.NoError(t, err) + assert.Len(t, tonChains, 1) + assert.Contains(t, tonChains, tonChain1.Selector) +} + +func TestLazyBlockChains_TryTonChains_Failure(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + tonChain1.Selector: chainsel.FamilyTon, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyTon: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get Ton chains - should return error + tonChains, err := lazyChains.TryTonChains() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load ton chain") + assert.Empty(t, tonChains) +} + +func TestLazyBlockChains_TryTronChains_Success(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + if selector == tronChain1.Selector { + return tronChain1, nil + } + + return nil, chain.ErrBlockChainNotFound + }, + } + + availableChains := map[uint64]string{ + tronChain1.Selector: chainsel.FamilyTron, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyTron: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get Tron chains - should succeed + tronChains, err := lazyChains.TryTronChains() + require.NoError(t, err) + assert.Len(t, tronChains, 1) + assert.Contains(t, tronChains, tronChain1.Selector) +} + +func TestLazyBlockChains_TryTronChains_Failure(t *testing.T) { + t.Parallel() + + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + return nil, assert.AnError + }, + } + + availableChains := map[uint64]string{ + tronChain1.Selector: chainsel.FamilyTron, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyTron: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get Tron chains - should return error + tronChains, err := lazyChains.TryTronChains() + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to load tron chain") + assert.Empty(t, tronChains) +} + +// TestLazyBlockChains_TryEVMChains_WithPointers tests that the generic tryChains +// function correctly handles both value and pointer return types from loaders. +func TestLazyBlockChains_TryEVMChains_WithPointers(t *testing.T) { + t.Parallel() + + // Test with loader that returns pointers + loader := &mockChainLoader{ + loadFunc: func(selector uint64) (chain.BlockChain, error) { + switch selector { + case evmChain1.Selector: + // Return pointer to test the PT case in tryChains type switch + chainCopy := evmChain1 + return &chainCopy, nil + case evmChain2.Selector: + // Return value to test the T case in tryChains type switch + return evmChain2, nil + default: + return nil, chain.ErrBlockChainNotFound + } + }, + } + + availableChains := map[uint64]string{ + evmChain1.Selector: chainsel.FamilyEVM, + evmChain2.Selector: chainsel.FamilyEVM, + } + loaders := map[string]chain.ChainLoader{ + chainsel.FamilyEVM: loader, + } + + lazyChains := chain.NewLazyBlockChains(t.Context(), availableChains, loaders, logger.Nop()) + + // Try to get EVM chains - should handle both pointers and values + evmChains, err := lazyChains.TryEVMChains() + require.NoError(t, err) + assert.Len(t, evmChains, 2, "should return 2 EVM chains regardless of pointer/value") + assert.Contains(t, evmChains, evmChain1.Selector) + assert.Contains(t, evmChains, evmChain2.Selector) + + // Verify the chains are properly dereferenced + assert.Equal(t, evmChain1.Selector, evmChains[evmChain1.Selector].Selector) + assert.Equal(t, evmChain2.Selector, evmChains[evmChain2.Selector].Selector) +} diff --git a/deployment/environment.go b/deployment/environment.go index 8b05e82c..a017c60d 100644 --- a/deployment/environment.go +++ b/deployment/environment.go @@ -59,7 +59,8 @@ type Environment struct { // OperationsBundle contains dependencies required by the operations API. OperationsBundle operations.Bundle // BlockChains is the container of all chains in the environment. - BlockChains chain.BlockChains + // This can be either an eagerly-loaded BlockChains or a LazyBlockChains that loads chains on-demand. + BlockChains chain.BlockChainCollection } // EnvironmentOption is a functional option for configuring an Environment @@ -75,7 +76,7 @@ func NewEnvironment( offchain offchain.Client, ctx func() context.Context, secrets ocr.OCRSecrets, - blockChains chain.BlockChains, + blockChains chain.BlockChainCollection, opts ...EnvironmentOption, ) *Environment { env := &Environment{ diff --git a/engine/cld/chains/chains.go b/engine/cld/chains/chains.go index ea7d220a..a8e93a3d 100644 --- a/engine/cld/chains/chains.go +++ b/engine/cld/chains/chains.go @@ -160,6 +160,63 @@ func LoadChains( return fchain.NewBlockChainsFromSlice(loadedChains), nil } +// NewLazyBlockChains creates a LazyBlockChains instance that defers chain loading until first access. +// This improves environment initialization performance by avoiding unnecessary chain connections. +// Chains are loaded on-demand when accessed via GetBySelector, EVMChains, SolanaChains, etc. +// All chains defined in the network config are made available for lazy loading. +// +// If a chain fails to load during access, the error is logged and the failing chain is skipped. +// This ensures graceful degradation - successfully loaded chains remain accessible while failures +// are visible in logs. +func NewLazyBlockChains( + ctx context.Context, + lggr logger.Logger, + cfg *config.Config, +) (*fchain.LazyBlockChains, error) { + chainLoaders := newChainLoaders(lggr, cfg.Networks, cfg.Env.Onchain) + + // Get all chain selectors from the network config + allChainSelectors := cfg.Networks.ChainSelectors() + + // Build a map of supported selectors (selector -> family) + supportedSelectors := make(map[uint64]string) + + for _, selector := range allChainSelectors { + // Get the chain family for this selector + chainFamily, err := chainsel.GetSelectorFamily(selector) + if err != nil { + lggr.Warnw("Unable to get chain family for selector", + "selector", selector, "error", err, + ) + + return nil, fmt.Errorf("unable to get chain family for selector %d", selector) + } + + // Check if we have a loader for this chain family + if _, exists := chainLoaders[chainFamily]; !exists { + lggr.Debugw("No chain loader available for chain family, skipping", + "selector", selector, "family", chainFamily, + ) + + continue + } + + supportedSelectors[selector] = chainFamily + } + + lggr.Infow("Created lazy blockchain collection", + "supported_selectors", len(supportedSelectors), + "families", len(chainLoaders), + ) + + fchainLoaders := make(map[string]ChainLoader, len(chainLoaders)) + for family, loader := range chainLoaders { + fchainLoaders[family] = loader + } + + return fchain.NewLazyBlockChains(ctx, supportedSelectors, fchainLoaders, lggr), nil +} + // newChainLoaders returns a map of chain loaders for each supported chain family, based on the provided // network config and secrets. Only chain loaders for which all required secrets are present will be created; // if any required secret is missing for a chain family, its loader is omitted and a warning is logged. @@ -211,6 +268,10 @@ func newChainLoaders( return loaders } +// ChainLoader is an alias for fchain.ChainLoader. +// This alias maintains backward compatibility for code that references chains.ChainLoader. +type ChainLoader = fchain.ChainLoader + var ( _ ChainLoader = &chainLoaderAptos{} _ ChainLoader = &chainLoaderSolana{} @@ -219,11 +280,6 @@ var ( _ ChainLoader = &chainLoaderSui{} ) -// ChainLoader is an interface that defines the methods for loading a chain. -type ChainLoader interface { - Load(ctx context.Context, selector uint64) (fchain.BlockChain, error) -} - // baseChainLoader is a base implementation of the ChainLoader interface. It contains the common // fields for all chain loaders. type baseChainLoader struct { diff --git a/engine/cld/environment/environment.go b/engine/cld/environment/environment.go index a8da689a..c688dde5 100644 --- a/engine/cld/environment/environment.go +++ b/engine/cld/environment/environment.go @@ -4,7 +4,9 @@ import ( "context" "errors" "fmt" + "os" + fchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" fdatastore "github.com/smartcontractkit/chainlink-deployments-framework/datastore" fdeployment "github.com/smartcontractkit/chainlink-deployments-framework/deployment" cldcatalog "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/catalog" @@ -75,17 +77,29 @@ func Load( lggr.Infow("Using file-based datastore") } - // default - loads all chains from the networks config - chainSelectorsToLoad := cfg.Networks.ChainSelectors() + var blockChains fchain.BlockChainCollection + if os.Getenv("CLD_LAZY_BLOCKCHAINS") == "true" { + lggr.Infow("Using lazy blockchains") + // Use lazy loading for chains - they will be initialized on first access + // All chains from the network config are made available, but only loaded when accessed + blockChains, err = chains.NewLazyBlockChains(ctx, lggr, cfg) + if err != nil { + return fdeployment.Environment{}, err + } + } else { + lggr.Infow("Using eager blockchains") + // default - loads all chains from the networks config + chainSelectorsToLoad := cfg.Networks.ChainSelectors() - if loadcfg.chainSelectorsToLoad != nil { - lggr.Infow("Override: loading chains", "chains", loadcfg.chainSelectorsToLoad) - chainSelectorsToLoad = loadcfg.chainSelectorsToLoad - } + if loadcfg.chainSelectorsToLoad != nil { + lggr.Infow("Override: loading chains", "chains", loadcfg.chainSelectorsToLoad) + chainSelectorsToLoad = loadcfg.chainSelectorsToLoad + } - blockChains, err := chains.LoadChains(ctx, lggr, cfg, chainSelectorsToLoad) - if err != nil { - return fdeployment.Environment{}, err + blockChains, err = chains.LoadChains(ctx, lggr, cfg, chainSelectorsToLoad) + if err != nil { + return fdeployment.Environment{}, err + } } nodes, err := envdir.LoadNodes() diff --git a/engine/cld/environment/environment_test.go b/engine/cld/environment/environment_test.go index 4fcad1db..02da8a07 100644 --- a/engine/cld/environment/environment_test.go +++ b/engine/cld/environment/environment_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + fchain "github.com/smartcontractkit/chainlink-deployments-framework/chain" fdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" ) @@ -84,6 +85,40 @@ func Test_Load_NoError(t *testing.T) { require.NoError(t, err) } +func Test_Load_WithLazyBlockchains(t *testing.T) { //nolint:paralleltest // Test sets environment variable + // Set the feature flag to enable lazy loading + t.Setenv("CLD_LAZY_BLOCKCHAINS", "true") + + // Set up domain + domain := setupTest(t, setupTestConfig, setupAddressbook, setupDataStore, setupNodes) + + env, err := Load(t.Context(), domain, "staging", WithoutJD(), OnlyLoadChainsFor([]uint64{})) + require.NoError(t, err) + + // Verify environment was created successfully with lazy blockchains + require.NotNil(t, env.BlockChains) + + // Verify we got a LazyBlockChains instance + assert.IsType(t, &fchain.LazyBlockChains{}, env.BlockChains, "Expected LazyBlockChains instance") +} + +func Test_Load_WithEagerBlockchains(t *testing.T) { + t.Parallel() + + // Explicitly don't set the feature flag - this tests the default eager loading behavior + // Set up domain + domain := setupTest(t, setupTestConfig, setupAddressbook, setupDataStore, setupNodes) + + env, err := Load(t.Context(), domain, "staging", WithoutJD(), OnlyLoadChainsFor([]uint64{})) + require.NoError(t, err) + + // Verify environment was created successfully with eager blockchains + require.NotNil(t, env.BlockChains) + + // Verify we got a BlockChains instance (not LazyBlockChains) + assert.IsType(t, fchain.BlockChains{}, env.BlockChains, "Expected BlockChains instance") +} + func setupTest(t *testing.T, setupFnc ...func(t *testing.T, domain fdomain.Domain)) fdomain.Domain { t.Helper() diff --git a/engine/cld/legacy/cli/mcmsv2/mcms_v2.go b/engine/cld/legacy/cli/mcmsv2/mcms_v2.go index de9cfb0b..893e52d6 100644 --- a/engine/cld/legacy/cli/mcmsv2/mcms_v2.go +++ b/engine/cld/legacy/cli/mcmsv2/mcms_v2.go @@ -77,7 +77,7 @@ type cfgv2 struct { proposal mcms.Proposal timelockProposal *mcms.TimelockProposal // nil if not a timelock proposal chainSelector uint64 - blockchains chain.BlockChains + blockchains chain.BlockChainCollection envStr string env cldf.Environment forkedEnv cldfenvironment.ForkedEnvironment diff --git a/engine/test/environment/environment_test.go b/engine/test/environment/environment_test.go index 0488fae3..b07a732d 100644 --- a/engine/test/environment/environment_test.go +++ b/engine/test/environment/environment_test.go @@ -188,7 +188,7 @@ func TestLoader_Load_ChainOptions(t *testing.T) { //nolint:paralleltest // We ar name string opts []LoadOpt wantBlockChainsLen int - assert func(t *testing.T, BlockChains fchain.BlockChains) + assert func(t *testing.T, BlockChains fchain.BlockChainCollection) }{ { name: "succeeds with no options resulting in no block chains", @@ -199,7 +199,7 @@ func TestLoader_Load_ChainOptions(t *testing.T) { //nolint:paralleltest // We ar name: "EVMSimulated with selectors", opts: []LoadOpt{WithEVMSimulated(t, []uint64{chainselectors.TEST_90000001.Selector})}, wantBlockChainsLen: 1, - assert: func(t *testing.T, BlockChains fchain.BlockChains) { + assert: func(t *testing.T, BlockChains fchain.BlockChainCollection) { t.Helper() require.Len(t, BlockChains.EVMChains(), 1) @@ -209,7 +209,7 @@ func TestLoader_Load_ChainOptions(t *testing.T) { //nolint:paralleltest // We ar name: "EVMSimulatedN", opts: []LoadOpt{WithEVMSimulatedN(t, 1)}, wantBlockChainsLen: 1, - assert: func(t *testing.T, BlockChains fchain.BlockChains) { + assert: func(t *testing.T, BlockChains fchain.BlockChainCollection) { t.Helper() require.Len(t, BlockChains.EVMChains(), 1) @@ -222,7 +222,7 @@ func TestLoader_Load_ChainOptions(t *testing.T) { //nolint:paralleltest // We ar BlockTime: 1 * time.Second, })}, wantBlockChainsLen: 1, - assert: func(t *testing.T, BlockChains fchain.BlockChains) { + assert: func(t *testing.T, BlockChains fchain.BlockChainCollection) { t.Helper() require.Len(t, BlockChains.EVMChains(), 1) @@ -235,7 +235,7 @@ func TestLoader_Load_ChainOptions(t *testing.T) { //nolint:paralleltest // We ar BlockTime: 1 * time.Second, })}, wantBlockChainsLen: 1, - assert: func(t *testing.T, BlockChains fchain.BlockChains) { + assert: func(t *testing.T, BlockChains fchain.BlockChainCollection) { t.Helper() require.Len(t, BlockChains.EVMChains(), 1) @@ -252,7 +252,7 @@ func TestLoader_Load_ChainOptions(t *testing.T) { //nolint:paralleltest // We ar WithSuiContainer(t, []uint64{chainselectors.SUI_LOCALNET.Selector}), }, wantBlockChainsLen: 6, - assert: func(t *testing.T, BlockChains fchain.BlockChains) { + assert: func(t *testing.T, BlockChains fchain.BlockChainCollection) { t.Helper() require.Len(t, BlockChains.EVMChains(), 1) // zksync is an EVM chain @@ -274,7 +274,7 @@ func TestLoader_Load_ChainOptions(t *testing.T) { //nolint:paralleltest // We ar WithSuiContainerN(t, 1), }, wantBlockChainsLen: 6, - assert: func(t *testing.T, BlockChains fchain.BlockChains) { + assert: func(t *testing.T, BlockChains fchain.BlockChainCollection) { t.Helper() require.Len(t, BlockChains.EVMChains(), 1) // zksync is an EVM chain @@ -293,7 +293,7 @@ func TestLoader_Load_ChainOptions(t *testing.T) { //nolint:paralleltest // We ar }), }, wantBlockChainsLen: 1, - assert: func(t *testing.T, BlockChains fchain.BlockChains) { + assert: func(t *testing.T, BlockChains fchain.BlockChainCollection) { t.Helper() require.Len(t, BlockChains.TonChains(), 1) }, @@ -306,7 +306,7 @@ func TestLoader_Load_ChainOptions(t *testing.T) { //nolint:paralleltest // We ar }), }, wantBlockChainsLen: 1, - assert: func(t *testing.T, BlockChains fchain.BlockChains) { + assert: func(t *testing.T, BlockChains fchain.BlockChainCollection) { t.Helper() require.Len(t, BlockChains.TonChains(), 1) }, diff --git a/experimental/analyzer/evm_analyzer_test.go b/experimental/analyzer/evm_analyzer_test.go index 1be8beb8..91b800a2 100644 --- a/experimental/analyzer/evm_analyzer_test.go +++ b/experimental/analyzer/evm_analyzer_test.go @@ -889,22 +889,24 @@ func TestTryEIP1967ProxyFallback(t *testing.T) { t.Helper() return &DefaultProposalContext{ - AddressesByChain: deployment.AddressesByChain{ - chainSelector: { - proxyAddress: deployment.MustTypeAndVersionFromString("TransparentUpgradeableProxy 1.0.0"), - }, - }, - evmRegistry: &mockEVMRegistry{ - abis: map[string]string{ - "TransparentUpgradeableProxy 1.0.0": proxyABI, - }, - addressesByChain: deployment.AddressesByChain{ + AddressesByChain: deployment.AddressesByChain{ chainSelector: { proxyAddress: deployment.MustTypeAndVersionFromString("TransparentUpgradeableProxy 1.0.0"), }, }, - }, - }, deployment.Environment{} + evmRegistry: &mockEVMRegistry{ + abis: map[string]string{ + "TransparentUpgradeableProxy 1.0.0": proxyABI, + }, + addressesByChain: deployment.AddressesByChain{ + chainSelector: { + proxyAddress: deployment.MustTypeAndVersionFromString("TransparentUpgradeableProxy 1.0.0"), + }, + }, + }, + }, deployment.Environment{ + BlockChains: chain.NewBlockChainsFromSlice([]chain.BlockChain{}), // empty blockchains + } }, chainSelector: chainSelector, proxyAddress: proxyAddress, @@ -1065,7 +1067,9 @@ func TestTryEIP1967ProxyFallback(t *testing.T) { setupCtx: func(t *testing.T) (ProposalContext, deployment.Environment) { t.Helper() - return mockProposalContext(t), deployment.Environment{} + return mockProposalContext(t), deployment.Environment{ + BlockChains: chain.NewBlockChainsFromSlice([]chain.BlockChain{}), // empty blockchains + } }, chainSelector: chainSelector, proxyAddress: proxyAddress, @@ -1523,22 +1527,24 @@ func TestAnalyzeEVMTransaction_EIP1967ProxyFallback(t *testing.T) { t.Helper() return &DefaultProposalContext{ - AddressesByChain: deployment.AddressesByChain{ - chainSelector: { - proxyAddress: deployment.MustTypeAndVersionFromString("TransparentUpgradeableProxy 1.0.0"), - }, - }, - evmRegistry: &mockEVMRegistry{ - abis: map[string]string{ - "TransparentUpgradeableProxy 1.0.0": proxyABI, - }, - addressesByChain: deployment.AddressesByChain{ + AddressesByChain: deployment.AddressesByChain{ chainSelector: { proxyAddress: deployment.MustTypeAndVersionFromString("TransparentUpgradeableProxy 1.0.0"), }, }, - }, - }, deployment.Environment{} + evmRegistry: &mockEVMRegistry{ + abis: map[string]string{ + "TransparentUpgradeableProxy 1.0.0": proxyABI, + }, + addressesByChain: deployment.AddressesByChain{ + chainSelector: { + proxyAddress: deployment.MustTypeAndVersionFromString("TransparentUpgradeableProxy 1.0.0"), + }, + }, + }, + }, deployment.Environment{ + BlockChains: chain.NewBlockChainsFromSlice([]chain.BlockChain{}), // empty blockchains + } }, expectedError: true, errorContains: "error analyzing operation", // Original error, not chain error