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..93d73efb --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/analyzer_context.go @@ -0,0 +1,40 @@ +package proposalanalysis + +import ( + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" +) + +var _ types.AnalyzerContext = &analyzerContext{} + +type analyzerContext struct { + proposal types.AnalyzedProposal + batchOperation types.AnalyzedBatchOperation + call types.AnalyzedCall +} + +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 +} + +// 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..fb08c137 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/decoder/decoder.go @@ -0,0 +1,79 @@ +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 { + evmABIMappings map[string]string + solanaDecoders map[string]experimentalanalyzer.DecodeInstructionFn +} + +// NewLegacyDecoder creates a decoder that wraps legacy experimental/analyzer decoding logic. +// Use functional options to configure: +// - WithEVMABIMappings: override proposal context EVM ABI mappings +// - WithSolanaDecoders: override proposal context Solana decoder mappings +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) + +// WithEVMABIMappings overrides the proposal context EVM ABI mappings used during decoding. +func WithEVMABIMappings(mappings map[string]string) DecoderOption { + return func(d *legacyDecoder) { + d.evmABIMappings = mappings + } +} + +// WithSolanaDecoders overrides the proposal context Solana decoder mappings used during decoding. +func WithSolanaDecoders(decoders map[string]experimentalanalyzer.DecodeInstructionFn) DecoderOption { + return func(d *legacyDecoder) { + d.solanaDecoders = decoders + } +} + +func (d *legacyDecoder) Decode( + ctx context.Context, + env deployment.Environment, + proposal *mcms.TimelockProposal, +) (types.DecodedTimelockProposal, error) { + proposalCtx, err := experimentalanalyzer.NewDefaultProposalContext(env, + experimentalanalyzer.WithEVMABIMappings(d.evmABIMappings), + experimentalanalyzer.WithSolanaDecoders(d.solanaDecoders), + ) + 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..0d4b6b77 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/decoder/decoder_test.go @@ -0,0 +1,24 @@ +package decoder_test + +import ( + "testing" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/decoder" + "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 configure registry options", func(t *testing.T) { + d := decoder.NewLegacyDecoder( + decoder.WithEVMABIMappings(nil), + decoder.WithSolanaDecoders(nil), + ) + require.NotNil(t, d) + }) +} 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..7a0bb407 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/decoder/legacy_adapter.go @@ -0,0 +1,123 @@ +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("{}") +} + +func (d *decodedCall) ContractType() string { + // return d.call.ContractType + return "" +} + +func (d *decodedCall) ContractVersion() string { + // return d.call.ContractVersion + return "" +} + +// 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) +} + +func (d *decodedParameter) Type() string { + // return d.field.Type + return "" +} + +// 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..64cfd156 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine.go @@ -0,0 +1,682 @@ +package proposalanalysis + +import ( + "context" + "errors" + "fmt" + "io" + "maps" + "slices" + "sync" + "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/internal" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/renderer" + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +type analyzerEngine struct { + proposalAnalyzers []types.ProposalAnalyzer + batchOperationAnalyzers []types.BatchOperationAnalyzer + callAnalyzers []types.CallAnalyzer + parameterAnalyzers []types.ParameterAnalyzer + + evmABIMappings map[string]string + solanaDecoders map[string]experimentalanalyzer.DecodeInstructionFn + + decoder decoder.ProposalDecoder + rendererRegistry *renderer.RendererRegistry + + executionContext types.ExecutionContext + 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 a logger and timeouts. +// Chain-specific EVM ABI mappings and Solana decoders are registered via RegisterEVMABIMappings and RegisterSolanaDecoders. +func NewAnalyzerEngine(opts ...EngineOption) types.AnalyzerEngine { + // Apply options to get configuration + cfg := ApplyEngineOptions(opts...) + + engine := &analyzerEngine{ + evmABIMappings: make(map[string]string), + solanaDecoders: make(map[string]experimentalanalyzer.DecodeInstructionFn), + decoder: decoder.NewLegacyDecoder(), + rendererRegistry: renderer.NewRendererRegistry(), + 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(ae.logger), + cldfenvironment.WithoutJD()) + if err != nil { + return nil, fmt.Errorf("failed to load environment: %w", err) + } + + ae.decoder = decoder.NewLegacyDecoder( + decoder.WithEVMABIMappings(ae.evmABIMappings), + decoder.WithSolanaDecoders(ae.solanaDecoders), + ) + + // 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{} + ectx := executionContext{ + domain: domain, + environmentName: environmentName, + blockChains: env.BlockChains, + dataStore: env.DataStore, + } + + 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 +} + +// Render writes the rendered proposal output to the provided io.Writer. +func (ae *analyzerEngine) Render( + ctx context.Context, + w io.Writer, + rendererID string, + proposal types.AnalyzedProposal, +) error { + r, exists := ae.rendererRegistry.Get(rendererID) + if !exists { + return fmt.Errorf("renderer %s not registered", rendererID) + } + + if ae.executionContext == nil { + return fmt.Errorf("execution context not available - ensure Run() was called before Render()") + } + + req := types.RendererRequest{ + Domain: ae.executionContext.Domain().String(), + EnvironmentName: ae.executionContext.EnvironmentName(), + } + + return r.Render(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) RegisterRenderer(r types.Renderer) error { + return ae.rendererRegistry.Register(r) +} + +func (ae *analyzerEngine) RegisterEVMABIMappings(evmABIMappings map[string]string) error { + if len(evmABIMappings) == 0 { + return fmt.Errorf("evm ABI mappings cannot be empty") + } + + for key, abi := range evmABIMappings { + if key == "" { + return fmt.Errorf("evm ABI mapping key cannot be empty") + } + if abi == "" { + return fmt.Errorf("evm ABI mapping value cannot be empty for key %q", key) + } + if _, exists := ae.evmABIMappings[key]; exists { + return fmt.Errorf("evm ABI mapping for key %q is already registered", key) + } + ae.evmABIMappings[key] = abi + } + + return nil +} + +func (ae *analyzerEngine) RegisterSolanaDecoders(solanaDecoders map[string]experimentalanalyzer.DecodeInstructionFn) error { + if len(solanaDecoders) == 0 { + return fmt.Errorf("solana decoders cannot be empty") + } + + for key, decodeFn := range solanaDecoders { + if key == "" { + return fmt.Errorf("solana decoder key cannot be empty") + } + if decodeFn == nil { + return fmt.Errorf("solana decoder cannot be nil for key %q", key) + } + if _, exists := ae.solanaDecoders[key]; exists { + return fmt.Errorf("solana decoder for key %q is already registered", key) + } + ae.solanaDecoders[key] = decodeFn + } + + return nil +} + +// 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 +} + +type analyzerExecutionResult struct { + analyzerID string + annotations types.Annotations + err error + timedOut bool + skipped bool +} + +func executeAnalyzerLevels( + ctx context.Context, + levels [][]types.BaseAnalyzer, + execute func(context.Context, types.BaseAnalyzer) analyzerExecutionResult, +) []analyzerExecutionResult { + results := make([]analyzerExecutionResult, 0) + for _, level := range levels { + levelResults := make([]analyzerExecutionResult, len(level)) + var wg sync.WaitGroup + for i, baseAnalyzer := range level { + wg.Add(1) + go func() { + defer wg.Done() + levelResults[i] = execute(ctx, baseAnalyzer) + }() + } + wg.Wait() + results = append(results, levelResults...) + } + return results +} + +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) + } + + levels := graph.Levels() + results := executeAnalyzerLevels(ctx, levels, func(ctx context.Context, baseAnalyzer types.BaseAnalyzer) analyzerExecutionResult { + proposalAnalyzer := baseAnalyzer.(types.ProposalAnalyzer) + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + if !proposalAnalyzer.CanAnalyze(ctx, req, decodedProposal) { + return analyzerExecutionResult{ + analyzerID: proposalAnalyzer.ID(), + skipped: true, + } + } + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := proposalAnalyzer.Analyze(analyzerCtx, req, decodedProposal) + timedOut := errors.Is(analyzerCtx.Err(), context.DeadlineExceeded) + cancel() // Always cancel to free resources + return analyzerExecutionResult{ + analyzerID: proposalAnalyzer.ID(), + annotations: annotations, + err: err, + timedOut: timedOut, + } + }) + for _, result := range results { + if result.skipped { + continue + } + if result.err != nil { + if result.timedOut { + ae.logger.Errorw("Proposal analyzer timed out", "analyzerID", result.analyzerID, "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Proposal analyzer failed", "analyzerID", result.analyzerID, "error", result.err) + } + continue + } + trackedAnnotations := trackAnnotations(result.annotations, result.analyzerID) + 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) + } + + levels := graph.Levels() + results := executeAnalyzerLevels(ctx, levels, func(ctx context.Context, baseAnalyzer types.BaseAnalyzer) analyzerExecutionResult { + batchOpAnalyzer := baseAnalyzer.(types.BatchOperationAnalyzer) + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + if !batchOpAnalyzer.CanAnalyze(ctx, req, decodedBatchOperation) { + return analyzerExecutionResult{ + analyzerID: batchOpAnalyzer.ID(), + skipped: true, + } + } + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := batchOpAnalyzer.Analyze(analyzerCtx, req, decodedBatchOperation) + timedOut := analyzerCtx.Err() == context.DeadlineExceeded + cancel() // Always cancel to free resources + return analyzerExecutionResult{ + analyzerID: batchOpAnalyzer.ID(), + annotations: annotations, + err: err, + timedOut: timedOut, + } + }) + for _, result := range results { + if result.skipped { + continue + } + if result.err != nil { + if result.timedOut { + ae.logger.Errorw("Batch operation analyzer timed out", "analyzerID", result.analyzerID, "chainSelector", decodedBatchOperation.ChainSelector(), "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Batch operation analyzer failed", "analyzerID", result.analyzerID, "chainSelector", decodedBatchOperation.ChainSelector(), "error", result.err) + } + continue + } + trackedAnnotations := trackAnnotations(result.annotations, result.analyzerID) + 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) + } + + levels := graph.Levels() + results := executeAnalyzerLevels(ctx, levels, func(ctx context.Context, baseAnalyzer types.BaseAnalyzer) analyzerExecutionResult { + callAnalyzer := baseAnalyzer.(types.CallAnalyzer) + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + if !callAnalyzer.CanAnalyze(ctx, req, decodedCall) { + return analyzerExecutionResult{ + analyzerID: callAnalyzer.ID(), + skipped: true, + } + } + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := callAnalyzer.Analyze(analyzerCtx, req, decodedCall) + timedOut := analyzerCtx.Err() == context.DeadlineExceeded + cancel() // Always cancel to free resources + return analyzerExecutionResult{ + analyzerID: callAnalyzer.ID(), + annotations: annotations, + err: err, + timedOut: timedOut, + } + }) + for _, result := range results { + if result.skipped { + continue + } + if result.err != nil { + if result.timedOut { + ae.logger.Errorw("Call analyzer timed out", "analyzerID", result.analyzerID, "callName", decodedCall.Name(), "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Call analyzer failed", "analyzerID", result.analyzerID, "callName", decodedCall.Name(), "error", result.err) + } + continue + } + trackedAnnotations := trackAnnotations(result.annotations, result.analyzerID) + 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) + } + + levels := graph.Levels() + results := executeAnalyzerLevels(ctx, levels, func(ctx context.Context, baseAnalyzer types.BaseAnalyzer) analyzerExecutionResult { + paramAnalyzer := baseAnalyzer.(types.ParameterAnalyzer) + req := types.AnalyzerRequest{ + AnalyzerContext: actx, + ExecutionContext: ectx, + } + if !paramAnalyzer.CanAnalyze(ctx, req, decodedParameter) { + return analyzerExecutionResult{ + analyzerID: paramAnalyzer.ID(), + skipped: true, + } + } + analyzerCtx, cancel := context.WithTimeout(ctx, ae.analyzerTimeout) + annotations, err := paramAnalyzer.Analyze(analyzerCtx, req, decodedParameter) + timedOut := analyzerCtx.Err() == context.DeadlineExceeded + cancel() // Always cancel to free resources + return analyzerExecutionResult{ + analyzerID: paramAnalyzer.ID(), + annotations: annotations, + err: err, + timedOut: timedOut, + } + }) + for _, result := range results { + if result.skipped { + continue + } + if result.err != nil { + if result.timedOut { + ae.logger.Errorw("Parameter analyzer timed out", "analyzerID", result.analyzerID, "paramName", decodedParameter.Name(), "paramType", decodedParameter.Type(), "timeout", ae.analyzerTimeout) + } else { + ae.logger.Errorw("Parameter analyzer failed", "analyzerID", result.analyzerID, "paramName", decodedParameter.Name(), "paramType", decodedParameter.Type(), "error", result.err) + } + continue + } + trackedAnnotations := trackAnnotations(result.annotations, result.analyzerID) + 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 +} + +func (a analyzedCall) ContractType() string { + return a.decodedCall.ContractType() +} + +func (a analyzedCall) ContractVersion() string { + return a.decodedCall.ContractVersion() +} + +// --------------------------------------------------------------------- + +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..09743e83 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine_options.go @@ -0,0 +1,77 @@ +package proposalanalysis + +import ( + "time" + + "github.com/smartcontractkit/chainlink-deployments-framework/pkg/logger" +) + +// 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 { + logger logger.Logger + analyzerTimeout time.Duration +} + +// 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 +} + +// 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..a80d8fb4 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine_options_test.go @@ -0,0 +1,65 @@ +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), + ) + + assert.NotNil(t, cfg.GetLogger()) + }) + + 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), + ) + + assert.NotNil(t, cfg.GetLogger()) + assert.Equal(t, customTimeout, cfg.GetAnalyzerTimeout()) + }) +} diff --git a/engine/cld/mcms/proposalanalysis/engine_test.go b/engine/cld/mcms/proposalanalysis/engine_test.go new file mode 100644 index 00000000..7fb5ee81 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/engine_test.go @@ -0,0 +1,101 @@ +package proposalanalysis + +import ( + "testing" + + "github.com/gagliardetto/solana-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + experimentalanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" + "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) + }) + + 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) + }) +} + +func TestEngineRegistryRegistration(t *testing.T) { + t.Run("register EVM ABI mappings", func(t *testing.T) { + engine := NewAnalyzerEngine() + concreteEngine, ok := engine.(*analyzerEngine) + require.True(t, ok) + + err := concreteEngine.RegisterEVMABIMappings(map[string]string{ + "MyContract 1.0.0": `[{"type":"function","name":"f","inputs":[]}]`, + }) + require.NoError(t, err) + assert.Len(t, concreteEngine.evmABIMappings, 1) + }) + + t.Run("reject duplicate EVM ABI mappings", func(t *testing.T) { + engine := NewAnalyzerEngine() + concreteEngine, ok := engine.(*analyzerEngine) + require.True(t, ok) + + firstErr := concreteEngine.RegisterEVMABIMappings(map[string]string{ + "MyContract 1.0.0": `[{"type":"function","name":"f","inputs":[]}]`, + }) + require.NoError(t, firstErr) + + err := concreteEngine.RegisterEVMABIMappings(map[string]string{ + "MyContract 1.0.0": `[{"type":"function","name":"g","inputs":[]}]`, + }) + require.Error(t, err) + assert.Equal(t, `evm ABI mapping for key "MyContract 1.0.0" is already registered`, err.Error()) + }) + + t.Run("register Solana decoders", func(t *testing.T) { + engine := NewAnalyzerEngine() + concreteEngine, ok := engine.(*analyzerEngine) + require.True(t, ok) + + err := concreteEngine.RegisterSolanaDecoders(map[string]experimentalanalyzer.DecodeInstructionFn{ + "MyProgram 1.0.0": func(_ []*solana.AccountMeta, _ []byte) (experimentalanalyzer.AnchorInstruction, error) { + return nil, nil + }, + }) + require.NoError(t, err) + assert.Len(t, concreteEngine.solanaDecoders, 1) + }) + + t.Run("reject duplicate Solana decoders", func(t *testing.T) { + engine := NewAnalyzerEngine() + concreteEngine, ok := engine.(*analyzerEngine) + require.True(t, ok) + + firstErr := concreteEngine.RegisterSolanaDecoders(map[string]experimentalanalyzer.DecodeInstructionFn{ + "MyProgram 1.0.0": func(_ []*solana.AccountMeta, _ []byte) (experimentalanalyzer.AnchorInstruction, error) { + return nil, nil + }, + }) + require.NoError(t, firstErr) + + err := concreteEngine.RegisterSolanaDecoders(map[string]experimentalanalyzer.DecodeInstructionFn{ + "MyProgram 1.0.0": func(_ []*solana.AccountMeta, _ []byte) (experimentalanalyzer.AnchorInstruction, error) { + return nil, nil + }, + }) + require.Error(t, err) + assert.Equal(t, `solana decoder for key "MyProgram 1.0.0" is already registered`, err.Error()) + }) +} 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/internal/dependency_graph.go b/engine/cld/mcms/proposalanalysis/internal/dependency_graph.go new file mode 100644 index 00000000..bfb68913 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/internal/dependency_graph.go @@ -0,0 +1,190 @@ +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 +} + +// Levels returns analyzers grouped by execution level (for parallel execution). +// Analyzers in the same level have no inter-dependencies and can run concurrently. +func (g *dependencyGraph) Levels() [][]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..d0e933d3 --- /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.Levels() + 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.Levels() + 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.Levels() + require.Len(t, levels, 1) + assert.Len(t, levels[0], 3) + }) +} diff --git a/engine/cld/mcms/proposalanalysis/renderer/renderer.go b/engine/cld/mcms/proposalanalysis/renderer/renderer.go new file mode 100644 index 00000000..bf779bb1 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/renderer/renderer.go @@ -0,0 +1,57 @@ +package renderer + +import ( + "fmt" + + "github.com/smartcontractkit/chainlink-deployments-framework/engine/cld/mcms/proposalanalysis/types" +) + +// RendererRegistry manages renderer registration and lookup +type RendererRegistry struct { + renderers map[string]types.Renderer +} + +// NewRendererRegistry creates a new renderer registry +func NewRendererRegistry() *RendererRegistry { + return &RendererRegistry{ + renderers: make(map[string]types.Renderer), + } +} + +// Register adds a renderer to the registry. +// Returns an error if: +// - renderer is nil +// - renderer ID is empty +// - a renderer with the same ID is already registered +func (r *RendererRegistry) Register(renderer types.Renderer) error { + if renderer == nil { + return fmt.Errorf("renderer cannot be nil") + } + + id := renderer.ID() + if id == "" { + return fmt.Errorf("renderer ID cannot be empty") + } + + if _, exists := r.renderers[id]; exists { + return fmt.Errorf("renderer with ID %q is already registered", id) + } + + r.renderers[id] = renderer + return nil +} + +// Get retrieves a renderer by ID +func (r *RendererRegistry) Get(id string) (types.Renderer, bool) { + renderer, ok := r.renderers[id] + return renderer, ok +} + +// List returns all registered renderer IDs +func (r *RendererRegistry) List() []string { + ids := make([]string, 0, len(r.renderers)) + for id := range r.renderers { + ids = append(ids, id) + } + return ids +} diff --git a/engine/cld/mcms/proposalanalysis/renderer/renderer_test.go b/engine/cld/mcms/proposalanalysis/renderer/renderer_test.go new file mode 100644 index 00000000..261ac715 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/renderer/renderer_test.go @@ -0,0 +1,114 @@ +package renderer + +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 renderer for testing +type mockRenderer struct { + id string +} + +func (m *mockRenderer) ID() string { + return m.id +} + +func (m *mockRenderer) Render(ctx context.Context, w io.Writer, req types.RendererRequest, proposal types.AnalyzedProposal) error { + _, err := w.Write([]byte("mock output")) + return err +} + +func TestRendererRegistry(t *testing.T) { + t.Run("Register and Get renderer", func(t *testing.T) { + registry := NewRendererRegistry() + renderer := &mockRenderer{id: "test-renderer"} + + err := registry.Register(renderer) + require.NoError(t, err) + + retrieved, ok := registry.Get("test-renderer") + assert.True(t, ok) + assert.Equal(t, renderer, retrieved) + }) + + t.Run("Register nil renderer returns error", func(t *testing.T) { + registry := NewRendererRegistry() + + err := registry.Register(nil) + require.ErrorContains(t, err, "cannot be nil") + }) + + t.Run("Register renderer with empty ID returns error", func(t *testing.T) { + registry := NewRendererRegistry() + renderer := &mockRenderer{id: ""} + + err := registry.Register(renderer) + require.ErrorContains(t, err, "cannot be empty") + }) + + t.Run("Register duplicate ID returns error", func(t *testing.T) { + registry := NewRendererRegistry() + renderer1 := &mockRenderer{id: "duplicate"} + renderer2 := &mockRenderer{id: "duplicate"} + + err := registry.Register(renderer1) + require.NoError(t, err) + + err = registry.Register(renderer2) + require.EqualError(t, err, `renderer with ID "duplicate" is already registered`) + + // Verify first renderer is still registered + retrieved, ok := registry.Get("duplicate") + assert.True(t, ok) + assert.Equal(t, renderer1, retrieved) + }) + + t.Run("Get non-existent renderer", func(t *testing.T) { + registry := NewRendererRegistry() + + retrieved, ok := registry.Get("non-existent") + assert.False(t, ok) + assert.Nil(t, retrieved) + }) + + t.Run("List renderers", func(t *testing.T) { + registry := NewRendererRegistry() + + renderer1 := &mockRenderer{id: "renderer-1"} + renderer2 := &mockRenderer{id: "renderer-2"} + renderer3 := &mockRenderer{id: "renderer-3"} + + registry.Register(renderer1) + registry.Register(renderer2) + registry.Register(renderer3) + + ids := registry.List() + assert.Len(t, ids, 3) + assert.ElementsMatch(t, []string{"renderer-1", "renderer-2", "renderer-3"}, ids) + }) + + t.Run("List empty registry", func(t *testing.T) { + registry := NewRendererRegistry() + + ids := registry.List() + assert.Empty(t, ids) + }) + + t.Run("Render writes to io.Writer", func(t *testing.T) { + renderer := &mockRenderer{id: "test-renderer"} + ctx := t.Context() + + // Example: Write to a bytes.Buffer + var buf bytes.Buffer + err := renderer.Render(ctx, &buf, types.RendererRequest{}, nil) + require.NoError(t, err) + assert.Equal(t, "mock output", buf.String()) + }) +} diff --git a/engine/cld/mcms/proposalanalysis/types/types.go b/engine/cld/mcms/proposalanalysis/types/types.go new file mode 100644 index 00000000..bdf54f63 --- /dev/null +++ b/engine/cld/mcms/proposalanalysis/types/types.go @@ -0,0 +1,191 @@ +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 + ContractType() string + ContractVersion() string +} + +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 + ContractType() string + ContractVersion() string +} + +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 + + // 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) +} + +// ----- renderer ----- + +// RendererRequest encapsulates the context passed to renderer methods. +type RendererRequest struct { + Domain string + EnvironmentName string +} + +// Renderer transforms an AnalyzedProposal into a specific output format +type Renderer interface { + ID() string + Render(ctx context.Context, w io.Writer, req RendererRequest, proposal AnalyzedProposal) error +} + +// ----- engine ----- + +type DecodeInstructionFn = experimentalanalyzer.DecodeInstructionFn + +type AnalyzerEngine interface { + Run(ctx context.Context, domain cldfdomain.Domain, environmentName string, proposal *mcms.TimelockProposal) (AnalyzedProposal, error) + + RegisterAnalyzer(analyzer BaseAnalyzer) error + + RegisterRenderer(renderer Renderer) error + + RegisterEVMABIMappings(evmABIMappings map[string]string) error + + RegisterSolanaDecoders(solanaDecoders map[string]DecodeInstructionFn) error + + Render(ctx context.Context, w io.Writer, rendererID string, proposal AnalyzedProposal) error +}