diff --git a/engine/cld/mcms/proposalanalysis/analyzer/annotated.go b/engine/cld/mcms/proposalanalysis/analyzer/annotated.go new file mode 100644 index 00000000..c1790eaf --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/analyzer/annotated.go @@ -0,0 +1,95 @@ +package analyzer + +import "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + +var _ types.Annotation = &annotation{} + +type annotation struct { + name string + atype string + value any + analyzerID string +} + +func (a annotation) Name() string { + return a.name +} + +func (a annotation) Type() string { + return a.atype +} + +func (a annotation) Value() any { + return a.value +} + +// NewAnnotation creates a new annotation with the given name, type, and value +func NewAnnotation(name, atype string, value any) types.Annotation { + return &annotation{ + name: name, + atype: atype, + value: value, + } +} + +// NewAnnotationWithAnalyzer creates a new annotation with analyzer ID tracking +func NewAnnotationWithAnalyzer(name, atype string, value any, analyzerID string) types.Annotation { + return &annotation{ + name: name, + atype: atype, + value: value, + analyzerID: analyzerID, + } +} + +// --------------------------------------------------------------------- + +var _ types.Annotated = &Annotated{} + +type Annotated struct { + annotations types.Annotations +} + +func (a *Annotated) AddAnnotations(annotations ...types.Annotation) { + a.annotations = append(a.annotations, annotations...) +} + +func (a Annotated) Annotations() types.Annotations { + return a.annotations +} + +// GetAnnotationsByName returns all annotations with the given name +func (a Annotated) GetAnnotationsByName(name string) types.Annotations { + var result types.Annotations + for _, ann := range a.annotations { + if ann.Name() == name { + result = append(result, ann) + } + } + return result +} + +// GetAnnotationsByType returns all annotations with the given type +func (a Annotated) GetAnnotationsByType(atype string) types.Annotations { + var result types.Annotations + for _, ann := range a.annotations { + if ann.Type() == atype { + result = append(result, ann) + } + } + return result +} + +// GetAnnotationsByAnalyzer returns all annotations created by the given analyzer ID +func (a Annotated) GetAnnotationsByAnalyzer(analyzerID string) types.Annotations { + var result types.Annotations + for _, ann := range a.annotations { + // Try to cast to our internal annotation type to access analyzerID + if internalAnn, ok := ann.(*annotation); ok { + if internalAnn.analyzerID == analyzerID { + result = append(result, ann) + } + } + } + return result +} diff --git a/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go b/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go new file mode 100644 index 00000000..321e8571 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/analyzer/annotations_test.go @@ -0,0 +1,122 @@ +package analyzer + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAnnotations(t *testing.T) { + ctx := context.Background() + _ = ctx + + t.Run("NewAnnotation", func(t *testing.T) { + ann := NewAnnotation("test", "INFO", "value") + assert.Equal(t, "test", ann.Name()) + assert.Equal(t, "INFO", ann.Type()) + assert.Equal(t, "value", ann.Value()) + }) + + t.Run("NewAnnotationWithAnalyzer", func(t *testing.T) { + ann := NewAnnotationWithAnalyzer("test", "WARN", "warning", "analyzer-1") + assert.Equal(t, "test", ann.Name()) + assert.Equal(t, "WARN", ann.Type()) + assert.Equal(t, "warning", ann.Value()) + }) + + t.Run("AddAnnotations", func(t *testing.T) { + a := &Annotated{} + ann1 := NewAnnotation("ann1", "INFO", "v1") + ann2 := NewAnnotation("ann2", "WARN", "v2") + + a.AddAnnotations(ann1) + assert.Len(t, a.Annotations(), 1) + + a.AddAnnotations(ann2) + assert.Len(t, a.Annotations(), 2) + }) + + t.Run("GetAnnotationsByName", func(t *testing.T) { + a := &Annotated{} + ann1 := NewAnnotation("gas-estimate", "INFO", 100) + ann2 := NewAnnotation("security-check", "WARN", "vulnerable") + ann3 := NewAnnotation("gas-estimate", "INFO", 200) + + a.AddAnnotations(ann1, ann2, ann3) + + results := a.GetAnnotationsByName("gas-estimate") + assert.Len(t, results, 2) + assert.Equal(t, "gas-estimate", results[0].Name()) + assert.Equal(t, "gas-estimate", results[1].Name()) + + results = a.GetAnnotationsByName("security-check") + assert.Len(t, results, 1) + assert.Equal(t, "security-check", results[0].Name()) + + results = a.GetAnnotationsByName("nonexistent") + assert.Len(t, results, 0) + }) + + t.Run("GetAnnotationsByType", func(t *testing.T) { + a := &Annotated{} + ann1 := NewAnnotation("ann1", "INFO", "v1") + ann2 := NewAnnotation("ann2", "WARN", "v2") + ann3 := NewAnnotation("ann3", "INFO", "v3") + ann4 := NewAnnotation("ann4", "ERROR", "v4") + + a.AddAnnotations(ann1, ann2, ann3, ann4) + + results := a.GetAnnotationsByType("INFO") + assert.Len(t, results, 2) + + results = a.GetAnnotationsByType("WARN") + assert.Len(t, results, 1) + + results = a.GetAnnotationsByType("ERROR") + assert.Len(t, results, 1) + + results = a.GetAnnotationsByType("DIFF") + assert.Len(t, results, 0) + }) + + t.Run("GetAnnotationsByAnalyzer", func(t *testing.T) { + a := &Annotated{} + ann1 := NewAnnotationWithAnalyzer("ann1", "INFO", "v1", "analyzer-1") + ann2 := NewAnnotationWithAnalyzer("ann2", "WARN", "v2", "analyzer-2") + ann3 := NewAnnotationWithAnalyzer("ann3", "INFO", "v3", "analyzer-1") + ann4 := NewAnnotation("ann4", "ERROR", "v4") // No analyzer ID + + a.AddAnnotations(ann1, ann2, ann3, ann4) + + results := a.GetAnnotationsByAnalyzer("analyzer-1") + assert.Len(t, results, 2) + + results = a.GetAnnotationsByAnalyzer("analyzer-2") + assert.Len(t, results, 1) + + results = a.GetAnnotationsByAnalyzer("analyzer-3") + assert.Len(t, results, 0) + }) + + t.Run("Combined queries", func(t *testing.T) { + a := &Annotated{} + ann1 := NewAnnotationWithAnalyzer("gas-estimate", "INFO", 100, "gas-analyzer") + ann2 := NewAnnotationWithAnalyzer("gas-estimate", "WARN", 500, "gas-analyzer") + ann3 := NewAnnotationWithAnalyzer("security", "WARN", "issue", "security-analyzer") + + a.AddAnnotations(ann1, ann2, ann3) + + // Get all gas-estimate annotations + gasAnnotations := a.GetAnnotationsByName("gas-estimate") + assert.Len(t, gasAnnotations, 2) + + // Get all WARN annotations + warnings := a.GetAnnotationsByType("WARN") + assert.Len(t, warnings, 2) + + // Get all annotations from gas-analyzer + gasAnalyzerAnnotations := a.GetAnnotationsByAnalyzer("gas-analyzer") + assert.Len(t, gasAnalyzerAnnotations, 2) + }) +} diff --git a/engine/cld/mcms/proposalanalysis/analyzer_context.go b/engine/cld/mcms/proposalanalysis/analyzer_context.go new file mode 100644 index 00000000..19c3099e --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/analyzer_context.go @@ -0,0 +1,51 @@ +package proposalanalysis + +import ( + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +var _ types.AnalyzerContext = &analyzerContext{} + +type analyzerContext struct { + proposal types.AnalyzedProposal + batchOperation types.AnalyzedBatchOperation + call types.AnalyzedCall + evmRegistry experimentalanalyzer.EVMABIRegistry + solanaRegistry experimentalanalyzer.SolanaDecoderRegistry +} + +func (ac *analyzerContext) Proposal() types.AnalyzedProposal { + return ac.proposal +} + +func (ac *analyzerContext) BatchOperation() types.AnalyzedBatchOperation { + return ac.batchOperation +} + +func (ac *analyzerContext) Call() types.AnalyzedCall { + return ac.call +} + +func (ac *analyzerContext) GetEVMRegistry() experimentalanalyzer.EVMABIRegistry { + return ac.evmRegistry +} + +func (ac *analyzerContext) GetSolanaRegistry() experimentalanalyzer.SolanaDecoderRegistry { + return ac.solanaRegistry +} + +// GetAnnotationsFrom returns annotations from a specific analyzer +func (ac *analyzerContext) GetAnnotationsFrom(analyzerID string) types.Annotations { + var annotations types.Annotations + if ac.call != nil { + annotations = append(annotations, ac.call.GetAnnotationsByAnalyzer(analyzerID)...) + } + if ac.batchOperation != nil { + annotations = append(annotations, ac.batchOperation.GetAnnotationsByAnalyzer(analyzerID)...) + } + if ac.proposal != nil { + annotations = append(annotations, ac.proposal.GetAnnotationsByAnalyzer(analyzerID)...) + } + return annotations +} diff --git a/engine/cld/mcms/proposalanalysis/decoder/decoder.go b/engine/cld/mcms/proposalanalysis/decoder/decoder.go new file mode 100644 index 00000000..efc3afe8 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/decoder/decoder.go @@ -0,0 +1,77 @@ +package decoder + +import ( + "context" + "fmt" + + "github.com/smartcontractkit/mcms" + + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +// ProposalDecoder decodes MCMS proposals into structured DecodedTimelockProposal +type ProposalDecoder interface { + Decode(ctx context.Context, env deployment.Environment, proposal *mcms.TimelockProposal) (types.DecodedTimelockProposal, error) +} + +// legacyDecoder adapts the legacy experimental/analyzer package to the new decoder interface +type legacyDecoder struct { + proposalContext experimentalanalyzer.ProposalContext +} + +// NewLegacyDecoder creates a decoder that wraps legacy experimental/analyzer decoding logic. +// Use functional options to configure: +// - WithProposalContext: provide a custom ProposalContext (otherwise default is created) +func NewLegacyDecoder(opts ...DecoderOption) ProposalDecoder { + decoder := &legacyDecoder{} + + for _, opt := range opts { + opt(decoder) + } + + return decoder +} + +// DecoderOption is a functional option for configuring the decoder +type DecoderOption func(*legacyDecoder) + +// WithProposalContext injects a custom ProposalContext for decoding. +// If not provided, a default context will be created during decoding. +func WithProposalContext(ctx experimentalanalyzer.ProposalContext) DecoderOption { + return func(d *legacyDecoder) { + d.proposalContext = ctx + } +} + +func (d *legacyDecoder) Decode( + ctx context.Context, + env deployment.Environment, + proposal *mcms.TimelockProposal, +) (types.DecodedTimelockProposal, error) { + // Create proposal context for legacy experimental analyzer + // Use the provided context if available, otherwise create a default one + var proposalCtx experimentalanalyzer.ProposalContext + + if d.proposalContext != nil { + proposalCtx = d.proposalContext + } else { + var err error + proposalCtx, err = experimentalanalyzer.NewDefaultProposalContext(env) + if err != nil { + return nil, fmt.Errorf("failed to create proposal context: %w", err) + } + } + + // Build the report using legacy experimental analyzer + report, err := experimentalanalyzer.BuildTimelockReport(ctx, proposalCtx, env, proposal) + if err != nil { + return nil, fmt.Errorf("failed to build timelock report: %w", err) + } + + // Convert to our DecodedTimelockProposal interface + return &decodedTimelockProposal{ + report: report, + }, nil +} diff --git a/engine/cld/mcms/proposalanalysis/decoder/decoder_test.go b/engine/cld/mcms/proposalanalysis/decoder/decoder_test.go new file mode 100644 index 00000000..b013fed3 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/decoder/decoder_test.go @@ -0,0 +1,49 @@ +package decoder_test + +import ( + "testing" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/decoder" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" + "github.com/stretchr/testify/require" +) + +// TestDecoderOptions verifies that decoder options work correctly +func TestDecoderOptions(t *testing.T) { + t.Run("can create decoder with no options", func(t *testing.T) { + d := decoder.NewLegacyDecoder() + require.NotNil(t, d) + }) + + t.Run("can inject custom proposal context", func(t *testing.T) { + customContext := &mockProposalContext{} + + d := decoder.NewLegacyDecoder( + decoder.WithProposalContext(customContext), + ) + require.NotNil(t, d) + }) +} + +// mockProposalContext is a minimal mock for testing +type mockProposalContext struct{} + +func (m *mockProposalContext) GetEVMRegistry() experimentalanalyzer.EVMABIRegistry { + return nil +} + +func (m *mockProposalContext) GetSolanaDecoderRegistry() experimentalanalyzer.SolanaDecoderRegistry { + return nil +} + +func (m *mockProposalContext) FieldsContext(chainSelector uint64) *experimentalanalyzer.FieldContext { + return nil +} + +func (m *mockProposalContext) GetRenderer() experimentalanalyzer.Renderer { + return nil +} + +func (m *mockProposalContext) SetRenderer(renderer experimentalanalyzer.Renderer) { + // no-op +} diff --git a/engine/cld/mcms/proposalanalysis/decoder/legacy_adapter.go b/engine/cld/mcms/proposalanalysis/decoder/legacy_adapter.go new file mode 100644 index 00000000..63bf1ed6 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/decoder/legacy_adapter.go @@ -0,0 +1,108 @@ +package decoder + +import ( + "encoding/json" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +// decodedTimelockProposal adapts legacy experimental/analyzer report to our interface +type decodedTimelockProposal struct { + report *experimentalanalyzer.ProposalReport +} + +func (d *decodedTimelockProposal) BatchOperations() types.DecodedBatchOperations { + batches := make(types.DecodedBatchOperations, len(d.report.Batches)) + for i, batch := range d.report.Batches { + batches[i] = &decodedBatchOperation{ + batch: batch, + } + } + return batches +} + +// decodedBatchOperation adapts legacy experimental batch report +type decodedBatchOperation struct { + batch experimentalanalyzer.BatchReport +} + +func (d *decodedBatchOperation) ChainSelector() uint64 { + return d.batch.ChainSelector +} + +func (d *decodedBatchOperation) Calls() types.DecodedCalls { + // Flatten all calls from all operations in the batch + var allCalls types.DecodedCalls + for _, op := range d.batch.Operations { + for _, call := range op.Calls { + allCalls = append(allCalls, &decodedCall{call: call}) + } + } + return allCalls +} + +// decodedCall adapts legacy experimental decoded call +type decodedCall struct { + call *experimentalanalyzer.DecodedCall +} + +func (d *decodedCall) To() string { + return d.call.Address +} + +func (d *decodedCall) Name() string { + return d.call.Method +} + +func (d *decodedCall) Inputs() types.DecodedParameters { + return convertNamedFields(d.call.Inputs) +} + +func (d *decodedCall) Outputs() types.DecodedParameters { + return convertNamedFields(d.call.Outputs) +} + +func (d *decodedCall) Data() []byte { + // Not directly available in legacy experimental analyzer, return empty + return []byte{} +} + +func (d *decodedCall) AdditionalFields() json.RawMessage { + // Not directly available in legacy experimental analyzer, return empty + return json.RawMessage("{}") +} + +// decodedParameter adapts legacy experimental named field +type decodedParameter struct { + field experimentalanalyzer.NamedField +} + +func (d *decodedParameter) Name() string { + return d.field.Name +} + +func (d *decodedParameter) Value() any { + return convertFieldValue(d.field.Value) +} + +// convertNamedFields converts legacy experimental NamedFields to DecodedParameters +func convertNamedFields(fields []experimentalanalyzer.NamedField) types.DecodedParameters { + params := make(types.DecodedParameters, len(fields)) + for i, field := range fields { + params[i] = &decodedParameter{field: field} + } + return params +} + +// convertFieldValue recursively converts legacy experimental FieldValue to simple types +func convertFieldValue(fv experimentalanalyzer.FieldValue) any { + if fv == nil { + return nil + } + + // Try to render the field value to a string + // The legacy experimental analyzer's FieldValue interface doesn't expose internal structure, + // so we use the rendering method + return fv.GetType() +} diff --git a/engine/cld/mcms/proposalanalysis/engine.go b/engine/cld/mcms/proposalanalysis/engine.go new file mode 100644 index 00000000..3241f9a2 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine.go @@ -0,0 +1,585 @@ +package proposalanalysis + +import ( + "context" + "fmt" + "io" + "maps" + "slices" + "time" + + "github.com/samber/lo" + "github.com/smartcontractkit/mcms" + mcmstypes "github.com/smartcontractkit/mcms/types" + + cldfdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + cldfenvironment "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/environment" + analyzer "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/analyzer" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/decoder" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/formatter" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/internal" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +type analyzerEngine struct { + proposalAnalyzers []types.ProposalAnalyzer + batchOperationAnalyzers []types.BatchOperationAnalyzer + callAnalyzers []types.CallAnalyzer + parameterAnalyzers []types.ParameterAnalyzer + + decoder decoder.ProposalDecoder + formatterRegistry *formatter.FormatterRegistry + evmRegistry experimentalanalyzer.EVMABIRegistry + solanaRegistry experimentalanalyzer.SolanaDecoderRegistry + executionContext types.ExecutionContext // Store for formatters + logger logger.Logger + analyzerTimeout time.Duration +} + +var _ types.AnalyzerEngine = &analyzerEngine{} + +// NewAnalyzerEngine creates a new analyzer engine +// Options can be provided to customize the engine behavior, such as injecting registries, logger, and timeouts +func NewAnalyzerEngine(opts ...EngineOption) types.AnalyzerEngine { + // Apply options to get configuration + cfg := ApplyEngineOptions(opts...) + + engine := &analyzerEngine{ + decoder: decoder.NewLegacyDecoder(), + formatterRegistry: formatter.NewFormatterRegistry(), + evmRegistry: cfg.GetEVMRegistry(), + solanaRegistry: cfg.GetSolanaRegistry(), + logger: cfg.GetLogger(), + analyzerTimeout: cfg.GetAnalyzerTimeout(), + } + return engine +} + +func (ae *analyzerEngine) Run( + ctx context.Context, + domain cldfdomain.Domain, + environmentName string, + proposal *mcms.TimelockProposal, +) (types.AnalyzedProposal, error) { + mcmsChainSelectors := slices.Sorted(maps.Keys(proposal.ChainMetadata)) + chainSelectors := lo.Map(mcmsChainSelectors, func(s mcmstypes.ChainSelector, _ int) uint64 { return uint64(s) }) + env, err := cldfenvironment.Load(ctx, domain, environmentName, + cldfenvironment.OnlyLoadChainsFor(chainSelectors), + // cldfenvironment.WithLogger(lggr), + cldfenvironment.WithoutJD()) + if err != nil { + return nil, fmt.Errorf("failed to load environment: %w", err) + } + + // Decode proposal + decodedProposal, err := ae.decoder.Decode(ctx, env, proposal) + if err != nil { + return nil, fmt.Errorf("failed to decode timelock proposal: %w", err) + } + + actx := &analyzerContext{ + evmRegistry: ae.evmRegistry, + solanaRegistry: ae.solanaRegistry, + } + ectx := executionContext{ + domain: domain, + environmentName: environmentName, + blockChains: env.BlockChains, + dataStore: env.DataStore, + } + + // Store execution context for formatters + ae.executionContext = &ectx + + analyzedProposal, err := ae.analyzeProposal(ctx, actx, ectx, decodedProposal) + if err != nil { + return nil, fmt.Errorf("failed to analyze timelock proposal: %w", err) + } + + return analyzedProposal, nil +} + +// Format writes the formatted proposal output to the provided io.Writer. +func (ae *analyzerEngine) Format( + ctx context.Context, + w io.Writer, + formatterID string, + proposal types.AnalyzedProposal, +) error { + f, exists := ae.formatterRegistry.Get(formatterID) + if !exists { + return fmt.Errorf("formatter %s not registered", formatterID) + } + + if ae.executionContext == nil { + return fmt.Errorf("execution context not available - ensure Run() was called before Format()") + } + + req := types.FormatterRequest{ + Domain: ae.executionContext.Domain().String(), + EnvironmentName: ae.executionContext.EnvironmentName(), + } + + return f.Format(ctx, w, req, proposal) +} + +func (ae *analyzerEngine) RegisterAnalyzer(baseAnalyzer types.BaseAnalyzer) error { + if baseAnalyzer == nil { + return fmt.Errorf("analyzer cannot be nil") + } + + id := baseAnalyzer.ID() + if id == "" { + return fmt.Errorf("analyzer ID cannot be empty") + } + + // Check for duplicate IDs across all analyzer types + if ae.hasAnalyzerID(id) { + return fmt.Errorf("analyzer with ID %q is already registered", id) + } + + switch a := baseAnalyzer.(type) { + case types.ProposalAnalyzer: + ae.proposalAnalyzers = append(ae.proposalAnalyzers, a) + case types.BatchOperationAnalyzer: + ae.batchOperationAnalyzers = append(ae.batchOperationAnalyzers, a) + case types.CallAnalyzer: + ae.callAnalyzers = append(ae.callAnalyzers, a) + case types.ParameterAnalyzer: + ae.parameterAnalyzers = append(ae.parameterAnalyzers, a) + default: + return fmt.Errorf("unknown analyzer type") + } + + return nil +} + +// hasAnalyzerID checks if an analyzer with the given ID is already registered +func (ae *analyzerEngine) hasAnalyzerID(id string) bool { + // Check proposal analyzers + for _, a := range ae.proposalAnalyzers { + if a.ID() == id { + return true + } + } + + // Check batch operation analyzers + for _, a := range ae.batchOperationAnalyzers { + if a.ID() == id { + return true + } + } + + // Check call analyzers + for _, a := range ae.callAnalyzers { + if a.ID() == id { + return true + } + } + + // Check parameter analyzers + for _, a := range ae.parameterAnalyzers { + if a.ID() == id { + return true + } + } + + return false +} + +func (ae *analyzerEngine) RegisterFormatter(f types.Formatter) error { + return ae.formatterRegistry.Register(f) +} + +// trackAnnotations wraps annotations with analyzer ID tracking. +// This allows annotations to be queried by analyzer ID using GetAnnotationsByAnalyzer. +func trackAnnotations(annotations types.Annotations, analyzerID string) types.Annotations { + tracked := make(types.Annotations, 0, len(annotations)) + for _, ann := range annotations { + tracked = append(tracked, analyzer.NewAnnotationWithAnalyzer( + ann.Name(), + ann.Type(), + ann.Value(), + analyzerID, + )) + } + return tracked +} + +func (ae *analyzerEngine) analyzeProposal( + ctx context.Context, + actx *analyzerContext, + ectx executionContext, + decodedProposal types.DecodedTimelockProposal, +) (types.AnalyzedProposal, error) { + proposal := &analyzedProposal{ + Annotated: &analyzer.Annotated{}, + decodedProposal: decodedProposal, + } + actx.proposal = proposal + + // STEP 1: Analyze batch operations first (bottom-up approach) + // This allows proposal analyzers to access annotations from batch operations + batchOps := make(types.AnalyzedBatchOperations, 0) + for _, batchOp := range decodedProposal.BatchOperations() { + analyzedBatchOp, err := ae.analyzeBatchOperation(ctx, actx, ectx, batchOp) + if err != nil { + ae.logger.Errorw("Failed to analyze batch operation", "chainSelector", batchOp.ChainSelector(), "error", err) + continue + } + batchOps = append(batchOps, analyzedBatchOp) + } + proposal.batchOperations = batchOps + + // STEP 2: Now run proposal analyzers + // They can access annotations from batch operations via AnalyzerContext + baseAnalyzers := make([]types.BaseAnalyzer, len(ae.proposalAnalyzers)) + for i, a := range ae.proposalAnalyzers { + baseAnalyzers[i] = a + } + + graph, err := internal.NewDependencyGraph(baseAnalyzers) + if err != nil { + return nil, fmt.Errorf("failed to build dependency graph for proposal analyzers: %w", err) + } + + sorted, err := graph.TopologicalSort() + if err != nil { + return nil, fmt.Errorf("failed to sort proposal analyzers: %w", err) + } + + // Execute proposal analyzers in dependency order + for _, baseAnalyzer := range sorted { + proposalAnalyzer := baseAnalyzer.(types.ProposalAnalyzer) + + // Create analyzer request + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + + // Check if analyzer can analyze this proposal + if !proposalAnalyzer.CanAnalyze(ctx, req, decodedProposal) { + continue + } + + // Execute analyzer with timeout + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := proposalAnalyzer.Analyze(analyzerCtx, req, decodedProposal) + cancel() // Always cancel to free resources + + if err != nil { + if analyzerCtx.Err() == context.DeadlineExceeded { + ae.logger.Errorw("Proposal analyzer timed out", "analyzerID", proposalAnalyzer.ID(), "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Proposal analyzer failed", "analyzerID", proposalAnalyzer.ID(), "error", err) + } + continue + } + // Track which analyzer created the annotations + trackedAnnotations := trackAnnotations(annotations, proposalAnalyzer.ID()) + proposal.AddAnnotations(trackedAnnotations...) + } + + return proposal, nil +} + +func (ae *analyzerEngine) analyzeBatchOperation( + ctx context.Context, + actx *analyzerContext, + ectx executionContext, + decodedBatchOperation types.DecodedBatchOperation, +) (types.AnalyzedBatchOperation, error) { + batchOp := &analyzedBatchOperation{ + Annotated: &analyzer.Annotated{}, + decodedBatchOperation: decodedBatchOperation, + } + actx.batchOperation = batchOp + + // STEP 1: Analyze calls first (bottom-up approach) + // This allows batch operation analyzers to access annotations from calls + calls := make(types.AnalyzedCalls, 0) + for _, call := range decodedBatchOperation.Calls() { + analyzedCall, err := ae.analyzeCall(ctx, actx, ectx, call) + if err != nil { + ae.logger.Errorw("Failed to analyze call", "callName", call.Name(), "error", err) + continue + } + calls = append(calls, analyzedCall) + } + batchOp.calls = calls + + // STEP 2: Now run batch operation analyzers + // They can access annotations from calls via AnalyzerContext + baseAnalyzers := make([]types.BaseAnalyzer, len(ae.batchOperationAnalyzers)) + for i, a := range ae.batchOperationAnalyzers { + baseAnalyzers[i] = a + } + + graph, err := internal.NewDependencyGraph(baseAnalyzers) + if err != nil { + return nil, fmt.Errorf("failed to build dependency graph for batch operation analyzers: %w", err) + } + + sorted, err := graph.TopologicalSort() + if err != nil { + return nil, fmt.Errorf("failed to sort batch operation analyzers: %w", err) + } + + // Execute batch operation analyzers + for _, baseAnalyzer := range sorted { + batchOpAnalyzer := baseAnalyzer.(types.BatchOperationAnalyzer) + + // Create analyzer request + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + + // Check if analyzer can analyze this batch operation + if !batchOpAnalyzer.CanAnalyze(ctx, req, decodedBatchOperation) { + continue + } + + // Execute analyzer with timeout + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := batchOpAnalyzer.Analyze(analyzerCtx, req, decodedBatchOperation) + cancel() // Always cancel to free resources + + if err != nil { + if analyzerCtx.Err() == context.DeadlineExceeded { + ae.logger.Errorw("Batch operation analyzer timed out", "analyzerID", batchOpAnalyzer.ID(), "chainSelector", decodedBatchOperation.ChainSelector(), "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Batch operation analyzer failed", "analyzerID", batchOpAnalyzer.ID(), "chainSelector", decodedBatchOperation.ChainSelector(), "error", err) + } + continue + } + trackedAnnotations := trackAnnotations(annotations, batchOpAnalyzer.ID()) + batchOp.AddAnnotations(trackedAnnotations...) + } + + return batchOp, nil +} + +func (ae *analyzerEngine) analyzeCall( + ctx context.Context, + actx *analyzerContext, + ectx executionContext, + decodedCall types.DecodedCall, +) (types.AnalyzedCall, error) { + call := &analyzedCall{ + Annotated: &analyzer.Annotated{}, + decodedCall: decodedCall, + } + actx.call = call + + // STEP 1: Analyze parameters first (bottom-up approach) + // This allows call analyzers to access annotations from parameters + inputs := make(types.AnalyzedParameters, 0) + for _, param := range decodedCall.Inputs() { + analyzedParam, err := ae.analyzeParameter(ctx, actx, ectx, param) + if err != nil { + ae.logger.Errorw("Failed to analyze input parameter", "paramName", param.Name(), "paramType", param.Type(), "error", err) + continue + } + inputs = append(inputs, analyzedParam) + } + + outputs := make(types.AnalyzedParameters, 0) + for _, param := range decodedCall.Outputs() { + analyzedParam, err := ae.analyzeParameter(ctx, actx, ectx, param) + if err != nil { + ae.logger.Errorw("Failed to analyze output parameter", "paramName", param.Name(), "paramType", param.Type(), "error", err) + continue + } + outputs = append(outputs, analyzedParam) + } + + call.inputs = inputs + call.outputs = outputs + + // STEP 2: Now run call analyzers + // They can access annotations from parameters via AnalyzerContext + baseAnalyzers := make([]types.BaseAnalyzer, len(ae.callAnalyzers)) + for i, a := range ae.callAnalyzers { + baseAnalyzers[i] = a + } + + graph, err := internal.NewDependencyGraph(baseAnalyzers) + if err != nil { + return nil, fmt.Errorf("failed to build dependency graph for call analyzers: %w", err) + } + + sorted, err := graph.TopologicalSort() + if err != nil { + return nil, fmt.Errorf("failed to sort call analyzers: %w", err) + } + + // Execute call analyzers + for _, baseAnalyzer := range sorted { + callAnalyzer := baseAnalyzer.(types.CallAnalyzer) + + // Create analyzer request + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + + // Check if analyzer can analyze this call + if !callAnalyzer.CanAnalyze(ctx, req, decodedCall) { + continue + } + + // Execute analyzer with timeout + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := callAnalyzer.Analyze(analyzerCtx, req, decodedCall) + cancel() // Always cancel to free resources + + if err != nil { + if analyzerCtx.Err() == context.DeadlineExceeded { + ae.logger.Errorw("Call analyzer timed out", "analyzerID", callAnalyzer.ID(), "callName", decodedCall.Name(), "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Call analyzer failed", "analyzerID", callAnalyzer.ID(), "callName", decodedCall.Name(), "error", err) + } + continue + } + trackedAnnotations := trackAnnotations(annotations, callAnalyzer.ID()) + call.AddAnnotations(trackedAnnotations...) + } + + return call, nil +} + +func (ae *analyzerEngine) analyzeParameter( + ctx context.Context, + actx *analyzerContext, + ectx executionContext, + decodedParameter types.DecodedParameter, +) (types.AnalyzedParameter, error) { + param := &analyzedParameter{ + Annotated: &analyzer.Annotated{}, + decodedParameter: decodedParameter, + } + + // Build dependency graph for parameter analyzers + baseAnalyzers := make([]types.BaseAnalyzer, len(ae.parameterAnalyzers)) + for i, a := range ae.parameterAnalyzers { + baseAnalyzers[i] = a + } + + graph, err := internal.NewDependencyGraph(baseAnalyzers) + if err != nil { + return nil, fmt.Errorf("failed to build dependency graph for parameter analyzers: %w", err) + } + + sorted, err := graph.TopologicalSort() + if err != nil { + return nil, fmt.Errorf("failed to sort parameter analyzers: %w", err) + } + + // Execute parameter analyzers + for _, baseAnalyzer := range sorted { + paramAnalyzer := baseAnalyzer.(types.ParameterAnalyzer) + + // Create analyzer request + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + + // Check if analyzer can analyze this parameter + if !paramAnalyzer.CanAnalyze(ctx, req, decodedParameter) { + continue + } + + // Execute analyzer with timeout + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := paramAnalyzer.Analyze(analyzerCtx, req, decodedParameter) + cancel() // Always cancel to free resources + + if err != nil { + if analyzerCtx.Err() == context.DeadlineExceeded { + ae.logger.Errorw("Parameter analyzer timed out", "analyzerID", paramAnalyzer.ID(), "paramName", decodedParameter.Name(), "paramType", decodedParameter.Type(), "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Parameter analyzer failed", "analyzerID", paramAnalyzer.ID(), "paramName", decodedParameter.Name(), "paramType", decodedParameter.Type(), "error", err) + } + continue + } + trackedAnnotations := trackAnnotations(annotations, paramAnalyzer.ID()) + param.AddAnnotations(trackedAnnotations...) + } + + return param, nil +} + +var _ types.AnalyzedProposal = &analyzedProposal{} + +type analyzedProposal struct { + *analyzer.Annotated + decodedProposal types.DecodedTimelockProposal + batchOperations types.AnalyzedBatchOperations +} + +func (a analyzedProposal) BatchOperations() types.AnalyzedBatchOperations { + return a.batchOperations +} + +// --------------------------------------------------------------------- + +var _ types.AnalyzedBatchOperation = &analyzedBatchOperation{} + +type analyzedBatchOperation struct { + *analyzer.Annotated + decodedBatchOperation types.DecodedBatchOperation + calls types.AnalyzedCalls +} + +func (a analyzedBatchOperation) Calls() types.AnalyzedCalls { + return a.calls +} + +// --------------------------------------------------------------------- + +var _ types.AnalyzedCall = &analyzedCall{} + +type analyzedCall struct { + *analyzer.Annotated + decodedCall types.DecodedCall + inputs types.AnalyzedParameters + outputs types.AnalyzedParameters +} + +func (a analyzedCall) Name() string { + return a.decodedCall.Name() +} + +func (a analyzedCall) Inputs() types.AnalyzedParameters { + return a.inputs +} + +func (a analyzedCall) Outputs() types.AnalyzedParameters { + return a.outputs +} + +// --------------------------------------------------------------------- + +var _ types.AnalyzedParameter = &analyzedParameter{} + +type analyzedParameter struct { + *analyzer.Annotated + decodedParameter types.DecodedParameter +} + +func (a analyzedParameter) Name() string { + return a.decodedParameter.Name() +} + +func (a analyzedParameter) Type() string { + return a.decodedParameter.Type() +} + +func (a analyzedParameter) Value() any { + return a.decodedParameter.Value() +} diff --git a/engine/cld/mcms/proposalanalysis/engine_options.go b/engine/cld/mcms/proposalanalysis/engine_options.go new file mode 100644 index 00000000..bc3fc45c --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine_options.go @@ -0,0 +1,120 @@ +package proposalanalysis + +import ( + "time" + + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +// Default timeout for analyzer execution +const DefaultAnalyzerTimeout = 5 * time.Minute + +// EngineOption configures the analyzer engine using the functional options pattern +type EngineOption func(*engineConfig) + +// engineConfig holds configuration for the analyzer engine +type engineConfig struct { + evmRegistry experimentalanalyzer.EVMABIRegistry + solanaRegistry experimentalanalyzer.SolanaDecoderRegistry + logger logger.Logger + analyzerTimeout time.Duration +} + +// WithEVMRegistry allows injecting an EVM ABI registry into the analyzer engine +// The registry will be made available to all analyzers through the AnalyzerContext +// +// Example: +// +// evmRegistry, _ := experimentalanalyzer.NewEnvironmentEVMRegistry(env, map[string]string{ +// "MyContract": "/path/to/abi.json", +// }) +// engine := internal.NewAnalyzerEngine(analyzer.WithEVMRegistry(evmRegistry)) +func WithEVMRegistry(registry experimentalanalyzer.EVMABIRegistry) EngineOption { + return func(cfg *engineConfig) { + cfg.evmRegistry = registry + } +} + +// WithSolanaRegistry allows injecting a Solana decoder registry into the analyzer engine +// The registry will be made available to all analyzers through the AnalyzerContext +// +// Example: +// +// solanaRegistry, _ := experimentalanalyzer.NewEnvironmentSolanaRegistry(env, map[string]DecodeInstructionFn{ +// "MyProgram": myDecoder, +// }) +// engine := internal.NewAnalyzerEngine(analyzer.WithSolanaRegistry(solanaRegistry)) +func WithSolanaRegistry(registry experimentalanalyzer.SolanaDecoderRegistry) EngineOption { + return func(cfg *engineConfig) { + cfg.solanaRegistry = registry + } +} + +// ApplyEngineOptions applies all engine options and returns the configuration +// This is used internally by the engine implementation +func ApplyEngineOptions(opts ...EngineOption) *engineConfig { + cfg := &engineConfig{} + for _, opt := range opts { + opt(cfg) + } + return cfg +} + +// GetEVMRegistry returns the EVM registry from the config +func (cfg *engineConfig) GetEVMRegistry() experimentalanalyzer.EVMABIRegistry { + return cfg.evmRegistry +} + +// GetSolanaRegistry returns the Solana registry from the config +func (cfg *engineConfig) GetSolanaRegistry() experimentalanalyzer.SolanaDecoderRegistry { + return cfg.solanaRegistry +} + +// WithLogger allows injecting a logger into the analyzer engine +// The logger will be used for logging errors and debug information during analysis +// If not provided, the engine will use a no-op logger +// +// Example: +// +// lggr, _ := logger.New() +// engine := proposalanalysis.NewAnalyzerEngine(proposalanalysis.WithLogger(lggr)) +func WithLogger(lggr logger.Logger) EngineOption { + return func(cfg *engineConfig) { + cfg.logger = lggr + } +} + +// GetLogger returns the logger from the config +// Returns a no-op logger if none was provided +func (cfg *engineConfig) GetLogger() logger.Logger { + if cfg.logger == nil { + return logger.Nop() + } + return cfg.logger +} + +// WithAnalyzerTimeout allows configuring the timeout for analyzer execution +// Each analyzer will be given this amount of time to complete before being cancelled +// This is important for analyzers that make network calls or other long-running operations +// Default is 5 minutes if not specified +// +// Example: +// +// engine := proposalanalysis.NewAnalyzerEngine( +// proposalanalysis.WithAnalyzerTimeout(2 * time.Minute), +// ) +func WithAnalyzerTimeout(timeout time.Duration) EngineOption { + return func(cfg *engineConfig) { + cfg.analyzerTimeout = timeout + } +} + +// GetAnalyzerTimeout returns the analyzer timeout from the config +// Returns DefaultAnalyzerTimeout (5 minutes) if none was provided +func (cfg *engineConfig) GetAnalyzerTimeout() time.Duration { + if cfg.analyzerTimeout == 0 { + return DefaultAnalyzerTimeout + } + return cfg.analyzerTimeout +} diff --git a/engine/cld/mcms/proposalanalysis/engine_options_test.go b/engine/cld/mcms/proposalanalysis/engine_options_test.go new file mode 100644 index 00000000..9a9eb182 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine_options_test.go @@ -0,0 +1,73 @@ +package proposalanalysis + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +func TestEngineOptions(t *testing.T) { + t.Run("WithLogger option sets logger", func(t *testing.T) { + lggr := logger.Test(t) + cfg := ApplyEngineOptions(WithLogger(lggr)) + + assert.NotNil(t, cfg.GetLogger()) + }) + + t.Run("GetLogger returns nop logger when not set", func(t *testing.T) { + cfg := ApplyEngineOptions() + + lggr := cfg.GetLogger() + assert.NotNil(t, lggr) + // Verify it's a nop logger by checking it doesn't panic when called + lggr.Info("test message") + lggr.Errorw("test error", "key", "value") + }) + + t.Run("multiple options can be combined", func(t *testing.T) { + lggr := logger.Test(t) + cfg := ApplyEngineOptions( + WithLogger(lggr), + WithEVMRegistry(nil), + WithSolanaRegistry(nil), + ) + + assert.NotNil(t, cfg.GetLogger()) + assert.Nil(t, cfg.GetEVMRegistry()) + assert.Nil(t, cfg.GetSolanaRegistry()) + }) + + t.Run("WithAnalyzerTimeout option sets timeout", func(t *testing.T) { + customTimeout := 2 * time.Minute + cfg := ApplyEngineOptions(WithAnalyzerTimeout(customTimeout)) + + assert.Equal(t, customTimeout, cfg.GetAnalyzerTimeout()) + }) + + t.Run("GetAnalyzerTimeout returns default when not set", func(t *testing.T) { + cfg := ApplyEngineOptions() + + timeout := cfg.GetAnalyzerTimeout() + assert.Equal(t, DefaultAnalyzerTimeout, timeout) + assert.Equal(t, 5*time.Minute, timeout) + }) + + t.Run("all options can be combined including timeout", func(t *testing.T) { + lggr := logger.Test(t) + customTimeout := 1 * time.Minute + cfg := ApplyEngineOptions( + WithLogger(lggr), + WithAnalyzerTimeout(customTimeout), + WithEVMRegistry(nil), + WithSolanaRegistry(nil), + ) + + assert.NotNil(t, cfg.GetLogger()) + assert.Equal(t, customTimeout, cfg.GetAnalyzerTimeout()) + assert.Nil(t, cfg.GetEVMRegistry()) + assert.Nil(t, cfg.GetSolanaRegistry()) + }) +} diff --git a/engine/cld/mcms/proposalanalysis/engine_test.go b/engine/cld/mcms/proposalanalysis/engine_test.go new file mode 100644 index 00000000..939d9284 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine_test.go @@ -0,0 +1,34 @@ +package proposalanalysis + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +func TestEngineWithLogger(t *testing.T) { + t.Run("engine accepts logger from options", func(t *testing.T) { + lggr := logger.Test(t) + engine := NewAnalyzerEngine(WithLogger(lggr)) + + assert.NotNil(t, engine) + // Verify the logger is set by checking the concrete type + concreteEngine, ok := engine.(*analyzerEngine) + require.True(t, ok) + assert.NotNil(t, concreteEngine.logger) + assert.Equal(t, "TestEngineWithLogger/engine_accepts_logger_from_options", concreteEngine.logger.Name()) + }) + + t.Run("engine uses nop logger when not provided", func(t *testing.T) { + engine := NewAnalyzerEngine() + + assert.NotNil(t, engine) + // Verify the logger is set (will be Nop logger) + concreteEngine, ok := engine.(*analyzerEngine) + require.True(t, ok) + assert.NotNil(t, concreteEngine.logger) + }) +} diff --git a/engine/cld/mcms/proposalanalysis/execution_context.go b/engine/cld/mcms/proposalanalysis/execution_context.go new file mode 100644 index 00000000..f7d4fa2e --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/execution_context.go @@ -0,0 +1,33 @@ +package proposalanalysis + +import ( + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldfdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" +) + +var _ types.ExecutionContext = &executionContext{} + +type executionContext struct { + domain cldfdomain.Domain + environmentName string + blockChains chain.BlockChains + dataStore datastore.DataStore +} + +func (ec executionContext) Domain() cldfdomain.Domain { + return ec.domain +} + +func (ec executionContext) EnvironmentName() string { + return ec.environmentName +} + +func (ec executionContext) BlockChains() chain.BlockChains { + return ec.blockChains +} + +func (ec executionContext) DataStore() datastore.DataStore { + return ec.dataStore +} diff --git a/engine/cld/mcms/proposalanalysis/formatter/formatter.go b/engine/cld/mcms/proposalanalysis/formatter/formatter.go new file mode 100644 index 00000000..13f660de --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/formatter/formatter.go @@ -0,0 +1,57 @@ +package formatter + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" +) + +// FormatterRegistry manages formatter registration and lookup +type FormatterRegistry struct { + formatters map[string]types.Formatter +} + +// NewFormatterRegistry creates a new formatter registry +func NewFormatterRegistry() *FormatterRegistry { + return &FormatterRegistry{ + formatters: make(map[string]types.Formatter), + } +} + +// Register adds a formatter to the registry. +// Returns an error if: +// - formatter is nil +// - formatter ID is empty +// - a formatter with the same ID is already registered +func (r *FormatterRegistry) Register(formatter types.Formatter) error { + if formatter == nil { + return fmt.Errorf("formatter cannot be nil") + } + + id := formatter.ID() + if id == "" { + return fmt.Errorf("formatter ID cannot be empty") + } + + if _, exists := r.formatters[id]; exists { + return fmt.Errorf("formatter with ID %q is already registered", id) + } + + r.formatters[id] = formatter + return nil +} + +// Get retrieves a formatter by ID +func (r *FormatterRegistry) Get(id string) (types.Formatter, bool) { + f, ok := r.formatters[id] + return f, ok +} + +// List returns all registered formatter IDs +func (r *FormatterRegistry) List() []string { + ids := make([]string, 0, len(r.formatters)) + for id := range r.formatters { + ids = append(ids, id) + } + return ids +} diff --git a/engine/cld/mcms/proposalanalysis/formatter/formatter_test.go b/engine/cld/mcms/proposalanalysis/formatter/formatter_test.go new file mode 100644 index 00000000..bc0ad907 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/formatter/formatter_test.go @@ -0,0 +1,114 @@ +package formatter + +import ( + "bytes" + "context" + "io" + "testing" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Mock formatter for testing +type mockFormatter struct { + id string +} + +func (m *mockFormatter) ID() string { + return m.id +} + +func (m *mockFormatter) Format(ctx context.Context, w io.Writer, req types.FormatterRequest, proposal types.AnalyzedProposal) error { + _, err := w.Write([]byte("mock output")) + return err +} + +func TestFormatterRegistry(t *testing.T) { + t.Run("Register and Get formatter", func(t *testing.T) { + registry := NewFormatterRegistry() + formatter := &mockFormatter{id: "test-formatter"} + + err := registry.Register(formatter) + require.NoError(t, err) + + retrieved, ok := registry.Get("test-formatter") + assert.True(t, ok) + assert.Equal(t, formatter, retrieved) + }) + + t.Run("Register nil formatter returns error", func(t *testing.T) { + registry := NewFormatterRegistry() + + err := registry.Register(nil) + require.ErrorContains(t, err, "cannot be nil") + }) + + t.Run("Register formatter with empty ID returns error", func(t *testing.T) { + registry := NewFormatterRegistry() + formatter := &mockFormatter{id: ""} + + err := registry.Register(formatter) + require.ErrorContains(t, err, "cannot be empty") + }) + + t.Run("Register duplicate ID returns error", func(t *testing.T) { + registry := NewFormatterRegistry() + formatter1 := &mockFormatter{id: "duplicate"} + formatter2 := &mockFormatter{id: "duplicate"} + + err := registry.Register(formatter1) + require.NoError(t, err) + + err = registry.Register(formatter2) + require.EqualError(t, err, `formatter with ID "duplicate" is already registered`) + + // Verify first formatter is still registered + retrieved, ok := registry.Get("duplicate") + assert.True(t, ok) + assert.Equal(t, formatter1, retrieved) + }) + + t.Run("Get non-existent formatter", func(t *testing.T) { + registry := NewFormatterRegistry() + + retrieved, ok := registry.Get("non-existent") + assert.False(t, ok) + assert.Nil(t, retrieved) + }) + + t.Run("List formatters", func(t *testing.T) { + registry := NewFormatterRegistry() + + formatter1 := &mockFormatter{id: "formatter-1"} + formatter2 := &mockFormatter{id: "formatter-2"} + formatter3 := &mockFormatter{id: "formatter-3"} + + registry.Register(formatter1) + registry.Register(formatter2) + registry.Register(formatter3) + + ids := registry.List() + assert.Len(t, ids, 3) + assert.ElementsMatch(t, []string{"formatter-1", "formatter-2", "formatter-3"}, ids) + }) + + t.Run("List empty registry", func(t *testing.T) { + registry := NewFormatterRegistry() + + ids := registry.List() + assert.Empty(t, ids) + }) + + t.Run("Format writes to io.Writer", func(t *testing.T) { + formatter := &mockFormatter{id: "test-formatter"} + ctx := context.Background() + + // Example: Write to a bytes.Buffer + var buf bytes.Buffer + err := formatter.Format(ctx, &buf, types.FormatterRequest{}, nil) + require.NoError(t, err) + assert.Equal(t, "mock output", buf.String()) + }) +} diff --git a/engine/cld/mcms/proposalanalysis/internal/dependency_graph.go b/engine/cld/mcms/proposalanalysis/internal/dependency_graph.go new file mode 100644 index 00000000..151ced29 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/internal/dependency_graph.go @@ -0,0 +1,189 @@ +package internal + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" +) + +// dependencyGraph represents a directed acyclic graph of analyzer dependencies +type dependencyGraph struct { + nodes map[string]*graphNode +} + +type graphNode struct { + analyzer types.BaseAnalyzer + dependencies []*graphNode + dependents []*graphNode +} + +// NewDependencyGraph creates a new dependency graph from a list of analyzers +func NewDependencyGraph(analyzers []types.BaseAnalyzer) (*dependencyGraph, error) { + graph := &dependencyGraph{ + nodes: make(map[string]*graphNode), + } + + // First pass: create nodes for all analyzers + for _, a := range analyzers { + if a == nil { + continue + } + id := a.ID() + if id == "" { + return nil, fmt.Errorf("analyzer must have a non-empty ID") + } + if _, exists := graph.nodes[id]; exists { + return nil, fmt.Errorf("duplicate analyzer ID: %s", id) + } + graph.nodes[id] = &graphNode{ + analyzer: a, + dependencies: []*graphNode{}, + dependents: []*graphNode{}, + } + } + + // Second pass: build dependency edges + for _, node := range graph.nodes { + depIDs := node.analyzer.Dependencies() + for _, depID := range depIDs { + if depID == "" { + continue + } + depNode, exists := graph.nodes[depID] + if !exists { + return nil, fmt.Errorf("analyzer %s depends on unknown analyzer %s", node.analyzer.ID(), depID) + } + node.dependencies = append(node.dependencies, depNode) + depNode.dependents = append(depNode.dependents, node) + } + } + + // Detect cycles + if err := graph.detectCycles(); err != nil { + return nil, err + } + + return graph, nil +} + +// detectCycles checks for circular dependencies using DFS +func (g *dependencyGraph) detectCycles() error { + visited := make(map[string]bool) + recStack := make(map[string]bool) + + for id, node := range g.nodes { + if !visited[id] { + if err := g.detectCyclesDFS(node, visited, recStack, []string{}); err != nil { + return err + } + } + } + + return nil +} + +func (g *dependencyGraph) detectCyclesDFS(node *graphNode, visited, recStack map[string]bool, path []string) error { + id := node.analyzer.ID() + visited[id] = true + recStack[id] = true + path = append(path, id) + + for _, dep := range node.dependencies { + depID := dep.analyzer.ID() + if !visited[depID] { + if err := g.detectCyclesDFS(dep, visited, recStack, path); err != nil { + return err + } + } else if recStack[depID] { + // Found a cycle + cyclePath := append(path, depID) + return fmt.Errorf("circular dependency detected: %v", cyclePath) + } + } + + recStack[id] = false + return nil +} + +// TopologicalSort returns analyzers in execution order (dependencies first) +func (g *dependencyGraph) TopologicalSort() ([]types.BaseAnalyzer, error) { + result := []types.BaseAnalyzer{} + visited := make(map[string]bool) + temp := make(map[string]bool) + + var visit func(*graphNode) error + visit = func(node *graphNode) error { + id := node.analyzer.ID() + if temp[id] { + return fmt.Errorf("cycle detected at %s", id) + } + if visited[id] { + return nil + } + + temp[id] = true + for _, dep := range node.dependencies { + if err := visit(dep); err != nil { + return err + } + } + temp[id] = false + visited[id] = true + result = append(result, node.analyzer) + return nil + } + + for _, node := range g.nodes { + if !visited[node.analyzer.ID()] { + if err := visit(node); err != nil { + return nil, err + } + } + } + + return result, nil +} + +// getLevels returns analyzers grouped by execution level (for parallel execution) +func (g *dependencyGraph) getLevels() [][]types.BaseAnalyzer { + inDegree := make(map[string]int) + for id, node := range g.nodes { + inDegree[id] = len(node.dependencies) + } + + var levels [][]types.BaseAnalyzer + remaining := len(g.nodes) + + for remaining > 0 { + var currentLevel []types.BaseAnalyzer + for id, node := range g.nodes { + if inDegree[id] == 0 { + currentLevel = append(currentLevel, node.analyzer) + } + } + + if len(currentLevel) == 0 { + // Should not happen if cycle detection worked + break + } + + levels = append(levels, currentLevel) + + // Remove nodes in current level and update in-degrees + for _, a := range currentLevel { + id := a.ID() + inDegree[id] = -1 // Mark as processed + remaining-- + + node := g.nodes[id] + for _, dependent := range node.dependents { + depID := dependent.analyzer.ID() + if inDegree[depID] > 0 { + inDegree[depID]-- + } + } + } + } + + return levels +} diff --git a/engine/cld/mcms/proposalanalysis/internal/dependency_graph_test.go b/engine/cld/mcms/proposalanalysis/internal/dependency_graph_test.go new file mode 100644 index 00000000..1c259e59 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/internal/dependency_graph_test.go @@ -0,0 +1,238 @@ +package internal + +import ( + "context" + "testing" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Mock analyzer for testing +type mockAnalyzer struct { + id string + dependencies []string +} + +func (m *mockAnalyzer) ID() string { + return m.id +} + +func (m *mockAnalyzer) Dependencies() []string { + return m.dependencies +} + +func TestNewDependencyGraph(t *testing.T) { + ctx := context.Background() + _ = ctx + + t.Run("empty graph", func(t *testing.T) { + graph, err := NewDependencyGraph([]types.BaseAnalyzer{}) + require.NoError(t, err) + assert.NotNil(t, graph) + assert.Empty(t, graph.nodes) + }) + + t.Run("single analyzer", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1"} + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a1}) + require.NoError(t, err) + assert.Len(t, graph.nodes, 1) + assert.Contains(t, graph.nodes, "a1") + }) + + t.Run("duplicate ID error", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a1"} + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2}) + require.Error(t, err) + assert.Contains(t, err.Error(), "duplicate analyzer ID") + }) + + t.Run("empty ID error", func(t *testing.T) { + a1 := &mockAnalyzer{id: ""} + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-empty ID") + }) + + t.Run("unknown dependency error", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1", dependencies: []string{"unknown"}} + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown analyzer") + }) +} + +func TestTopologicalSort(t *testing.T) { + ctx := context.Background() + _ = ctx + + t.Run("linear dependency chain", func(t *testing.T) { + // a1 -> a2 -> a3 + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + a3 := &mockAnalyzer{id: "a3", dependencies: []string{"a2"}} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a3, a1, a2}) + require.NoError(t, err) + + sorted, err := graph.TopologicalSort() + require.NoError(t, err) + require.Len(t, sorted, 3) + + // a1 should come before a2, a2 before a3 + ids := make([]string, len(sorted)) + for i, a := range sorted { + ids[i] = a.ID() + } + assert.Equal(t, []string{"a1", "a2", "a3"}, ids) + }) + + t.Run("diamond dependency", func(t *testing.T) { + // a1 + // / \ + // a2 a3 + // \ / + // a4 + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + a3 := &mockAnalyzer{id: "a3", dependencies: []string{"a1"}} + a4 := &mockAnalyzer{id: "a4", dependencies: []string{"a2", "a3"}} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a4, a2, a3, a1}) + require.NoError(t, err) + + sorted, err := graph.TopologicalSort() + require.NoError(t, err) + require.Len(t, sorted, 4) + + // Build position map + pos := make(map[string]int) + for i, a := range sorted { + pos[a.ID()] = i + } + + // Assert ordering constraints + assert.Less(t, pos["a1"], pos["a2"]) + assert.Less(t, pos["a1"], pos["a3"]) + assert.Less(t, pos["a2"], pos["a4"]) + assert.Less(t, pos["a3"], pos["a4"]) + }) + + t.Run("independent analyzers", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2"} + a3 := &mockAnalyzer{id: "a3"} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2, a3}) + require.NoError(t, err) + + sorted, err := graph.TopologicalSort() + require.NoError(t, err) + assert.Len(t, sorted, 3) + }) +} + +func TestDetectCycles(t *testing.T) { + ctx := context.Background() + _ = ctx + + t.Run("simple cycle", func(t *testing.T) { + // a1 -> a2 -> a1 (cycle) + a1 := &mockAnalyzer{id: "a1", dependencies: []string{"a2"}} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2}) + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") + }) + + t.Run("self dependency", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1", dependencies: []string{"a1"}} + + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1}) + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") + }) + + t.Run("complex cycle", func(t *testing.T) { + // a1 -> a2 -> a3 -> a1 (cycle) + a1 := &mockAnalyzer{id: "a1", dependencies: []string{"a3"}} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + a3 := &mockAnalyzer{id: "a3", dependencies: []string{"a2"}} + + _, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2, a3}) + require.Error(t, err) + assert.Contains(t, err.Error(), "circular dependency") + }) +} + +func TestGetLevels(t *testing.T) { + ctx := context.Background() + _ = ctx + + t.Run("linear chain has sequential levels", func(t *testing.T) { + // a1 -> a2 -> a3 + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + a3 := &mockAnalyzer{id: "a3", dependencies: []string{"a2"}} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2, a3}) + require.NoError(t, err) + + levels := graph.getLevels() + require.Len(t, levels, 3) + assert.Len(t, levels[0], 1) + assert.Equal(t, "a1", levels[0][0].ID()) + assert.Len(t, levels[1], 1) + assert.Equal(t, "a2", levels[1][0].ID()) + assert.Len(t, levels[2], 1) + assert.Equal(t, "a3", levels[2][0].ID()) + }) + + t.Run("diamond allows parallel execution", func(t *testing.T) { + // a1 + // / \ + // a2 a3 + // \ / + // a4 + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2", dependencies: []string{"a1"}} + a3 := &mockAnalyzer{id: "a3", dependencies: []string{"a1"}} + a4 := &mockAnalyzer{id: "a4", dependencies: []string{"a2", "a3"}} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2, a3, a4}) + require.NoError(t, err) + + levels := graph.getLevels() + require.Len(t, levels, 3) + + // Level 0: a1 + assert.Len(t, levels[0], 1) + assert.Equal(t, "a1", levels[0][0].ID()) + + // Level 1: a2 and a3 (can run in parallel) + assert.Len(t, levels[1], 2) + ids := []string{levels[1][0].ID(), levels[1][1].ID()} + assert.ElementsMatch(t, []string{"a2", "a3"}, ids) + + // Level 2: a4 + assert.Len(t, levels[2], 1) + assert.Equal(t, "a4", levels[2][0].ID()) + }) + + t.Run("independent analyzers in same level", func(t *testing.T) { + a1 := &mockAnalyzer{id: "a1"} + a2 := &mockAnalyzer{id: "a2"} + a3 := &mockAnalyzer{id: "a3"} + + graph, err := NewDependencyGraph([]types.BaseAnalyzer{a1, a2, a3}) + require.NoError(t, err) + + levels := graph.getLevels() + require.Len(t, levels, 1) + assert.Len(t, levels[0], 3) + }) +} diff --git a/engine/cld/mcms/proposalanalysis/types/types.go b/engine/cld/mcms/proposalanalysis/types/types.go new file mode 100644 index 00000000..f2cebbc1 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/types/types.go @@ -0,0 +1,189 @@ +package types + +import ( + "context" + "encoding/json" + "io" + + "github.com/smartcontractkit/mcms" + + "github.com/smartcontractkit/chainlink-deployments-framework/chain" + "github.com/smartcontractkit/chainlink-deployments-framework/datastore" + cldfdomain "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/domain" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +// ----- annotation ----- + +type Annotation interface { + Name() string + Type() string + Value() any +} + +type Annotations []Annotation + +type Annotated interface { + AddAnnotations(annotations ...Annotation) + Annotations() Annotations + GetAnnotationsByName(name string) Annotations + GetAnnotationsByType(atype string) Annotations + GetAnnotationsByAnalyzer(analyzerID string) Annotations +} + +// ----- decoded ----- + +type DecodedTimelockProposal interface { + BatchOperations() DecodedBatchOperations +} + +type DecodedBatchOperations []DecodedBatchOperation + +type DecodedBatchOperation interface { + ChainSelector() uint64 + Calls() DecodedCalls +} + +type DecodedCalls []DecodedCall + +type DecodedCall interface { // DecodedCall or DecodedTransaction? + To() string // review: current analyzer uses "Address" + Name() string // review: current analyzer uses "Method" + Inputs() DecodedParameters + Outputs() DecodedParameters + Data() []byte + AdditionalFields() json.RawMessage +} + +type DecodedParameters []DecodedParameter + +type DecodedParameter interface { + Name() string + Type() string + Value() any +} + +// ----- analyzed ----- + +type AnalyzedProposal interface { + Annotated + BatchOperations() AnalyzedBatchOperations +} + +type AnalyzedBatchOperation interface { + Annotated + Calls() AnalyzedCalls +} + +type AnalyzedBatchOperations []AnalyzedBatchOperation + +type AnalyzedCalls []AnalyzedCall + +type AnalyzedCall interface { + Annotated + Name() string + Inputs() AnalyzedParameters + Outputs() AnalyzedParameters +} + +type AnalyzedParameters []AnalyzedParameter + +type AnalyzedParameter interface { + Annotated + Name() string + Type() string // reflect.Type? + Value() any // reflect.Value? +} + +// ----- contexts ----- + +type AnalyzerContext interface { + Proposal() AnalyzedProposal + BatchOperation() AnalyzedBatchOperation + Call() AnalyzedCall + + // GetEVMRegistry returns the EVM ABI registry for chain-specific decoding + // Returns nil if no custom registry was provided + GetEVMRegistry() experimentalanalyzer.EVMABIRegistry + + // GetSolanaRegistry returns the Solana decoder registry for chain-specific decoding + // Returns nil if no custom registry was provided + GetSolanaRegistry() experimentalanalyzer.SolanaDecoderRegistry + + // GetAnnotationsFrom returns annotations from a specific analyzer at the current context level. + // For ProposalAnalyzers, this queries the proposal; for CallAnalyzers, the call; etc. + // This is useful for accessing results from dependency analyzers. + // Returns empty slice if the analyzer ID is not found or no annotations exist. + GetAnnotationsFrom(analyzerID string) Annotations +} + +type ExecutionContext interface { + Domain() cldfdomain.Domain + EnvironmentName() string + BlockChains() chain.BlockChains + DataStore() datastore.DataStore + // Environment() Environment +} + +// AnalyzerRequest encapsulates the analyzer and execution contexts passed to analyzer methods. +type AnalyzerRequest struct { + AnalyzerContext AnalyzerContext + ExecutionContext ExecutionContext +} + +// ----- analyzers ----- + +type BaseAnalyzer interface { + ID() string + Dependencies() []string // Returns IDs of dependent analyzers +} + +type ProposalAnalyzer interface { + BaseAnalyzer + CanAnalyze(ctx context.Context, req AnalyzerRequest, proposal DecodedTimelockProposal) bool + Analyze(ctx context.Context, req AnalyzerRequest, proposal DecodedTimelockProposal) (Annotations, error) +} + +type BatchOperationAnalyzer interface { + BaseAnalyzer + CanAnalyze(ctx context.Context, req AnalyzerRequest, operation DecodedBatchOperation) bool + Analyze(ctx context.Context, req AnalyzerRequest, operation DecodedBatchOperation) (Annotations, error) +} + +type CallAnalyzer interface { + BaseAnalyzer + CanAnalyze(ctx context.Context, req AnalyzerRequest, call DecodedCall) bool + Analyze(ctx context.Context, req AnalyzerRequest, call DecodedCall) (Annotations, error) +} + +type ParameterAnalyzer interface { + BaseAnalyzer + CanAnalyze(ctx context.Context, req AnalyzerRequest, param DecodedParameter) bool + Analyze(ctx context.Context, req AnalyzerRequest, param DecodedParameter) (Annotations, error) +} + +// ----- formatter ----- + +// FormatterRequest encapsulates the context passed to formatter methods. +type FormatterRequest struct { + Domain string + EnvironmentName string +} + +// Formatter transforms an AnalyzedProposal into a specific output format +type Formatter interface { + ID() string + Format(ctx context.Context, w io.Writer, req FormatterRequest, proposal AnalyzedProposal) error +} + +// ----- engine ----- + +type AnalyzerEngine interface { + Run(ctx context.Context, domain cldfdomain.Domain, environmentName string, proposal *mcms.TimelockProposal) (AnalyzedProposal, error) + + RegisterAnalyzer(analyzer BaseAnalyzer) error + + RegisterFormatter(formatter Formatter) error + + Format(ctx context.Context, w io.Writer, formatterID string, proposal AnalyzedProposal) error +}