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