From 79ff513b72555d7227e5b2013f5d8e12f2fe7366 Mon Sep 17 00:00:00 2001 From: JohnChangUK Date: Mon, 8 Jun 2026 20:47:22 +0100 Subject: [PATCH] feat: Canton MCMS proposal analyzer --- .changeset/canton-proposal-analyzer.md | 5 + experimental/analyzer/canton_analyzer.go | 134 +++ experimental/analyzer/canton_analyzer_test.go | 453 +++++++++++ experimental/analyzer/renderer_markdown.go | 8 +- .../analyzer/renderer_markdown_test.go | 12 +- experimental/analyzer/report_builder.go | 2 + .../testdata/canton_test_proposal.json | 770 ++++++++++++++++++ experimental/analyzer/upf/upf.go | 13 + experimental/analyzer/upf/upf_canton_test.go | 59 ++ experimental/analyzer/upf/upf_test.go | 65 ++ experimental/analyzer/utils.go | 5 +- experimental/analyzer/utils_test.go | 27 + go.mod | 6 +- go.sum | 4 +- 14 files changed, 1549 insertions(+), 14 deletions(-) create mode 100644 .changeset/canton-proposal-analyzer.md create mode 100644 experimental/analyzer/canton_analyzer.go create mode 100644 experimental/analyzer/canton_analyzer_test.go create mode 100644 experimental/analyzer/testdata/canton_test_proposal.json create mode 100644 experimental/analyzer/upf/upf_canton_test.go create mode 100644 experimental/analyzer/utils_test.go diff --git a/.changeset/canton-proposal-analyzer.md b/.changeset/canton-proposal-analyzer.md new file mode 100644 index 000000000..31cd21bbd --- /dev/null +++ b/.changeset/canton-proposal-analyzer.md @@ -0,0 +1,5 @@ +--- +"chainlink-deployments-framework": minor +--- + +feat(analyzer): implement Canton MCMS proposal analyzer diff --git a/experimental/analyzer/canton_analyzer.go b/experimental/analyzer/canton_analyzer.go new file mode 100644 index 000000000..7162cdbd8 --- /dev/null +++ b/experimental/analyzer/canton_analyzer.go @@ -0,0 +1,134 @@ +package analyzer + +import ( + "encoding/json" + "fmt" + "reflect" + "sort" + + mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmscantonsdk "github.com/smartcontractkit/mcms/sdk/canton" + "github.com/smartcontractkit/mcms/types" +) + +func AnalyzeCantonTransactions(ctx ProposalContext, chainSelector uint64, txs []types.Transaction) ([]*DecodedCall, error) { + decoder := mcmscantonsdk.NewDecoder() + decodedTxs := make([]*DecodedCall, len(txs)) + for i, op := range txs { + analyzedTransaction, err := AnalyzeCantonTransaction(ctx, decoder, chainSelector, op) + if err != nil { + return nil, fmt.Errorf("failed to analyze Canton transaction %d: %w", i, err) + } + decodedTxs[i] = analyzedTransaction + } + + return decodedTxs, nil +} + +// AnalyzeCantonTransaction decodes a single Canton MCMS transaction. Each transaction already +// describes the target call (Daml choice or factory deploy) via its AdditionalFields, so it is decoded directly +func AnalyzeCantonTransaction(ctx ProposalContext, decoder *mcmscantonsdk.Decoder, chainSelector uint64, mcmsTx types.Transaction) (*DecodedCall, error) { + contractType, contractVersion := resolveContractInfo(ctx, chainSelector, mcmsTx) + + var additionalFields mcmscantonsdk.AdditionalFields + if err := json.Unmarshal(mcmsTx.AdditionalFields, &additionalFields); err != nil { + return nil, fmt.Errorf("failed to unmarshal Canton additional fields: %w", err) + } + + // Pass the resolved contract type as the fallback contract-type key + // The decoder prefers the entity name parsed from AdditionalFields.TargetTemplateID when present. + decodedOp, err := decoder.Decode(mcmsTx, contractType) + if err != nil { + errStr := fmt.Errorf("failed to decode Canton transaction %q: %w", additionalFields.FunctionName, err) + + return &DecodedCall{ + Address: mcmsTx.To, + Method: errStr.Error(), + ContractType: contractType, + ContractVersion: contractVersion, + }, nil + } + + namedArgs, err := cantonToNamedFields(decodedOp) + if err != nil { + return nil, fmt.Errorf("failed to convert decoded operation to named arguments: %w", err) + } + + return &DecodedCall{ + Address: mcmsTx.To, + Method: decodedOp.MethodName(), + Inputs: namedArgs, + Outputs: []NamedField{}, + ContractType: contractType, + ContractVersion: contractVersion, + }, nil +} + +// cantonToNamedFields is like toNamedFields but uses cantonFieldValue so that nested Daml records +// (returned as map[string]any by the Canton decoder's toDisplayArg) become StructField. +// Kept here rather than in utils.go to avoid leaking Canton-specific logic into the shared utility. +func cantonToNamedFields(decodedOp mcmssdk.DecodedOperation) ([]NamedField, error) { + args := decodedOp.Args() + keys := decodedOp.Keys() + if len(keys) != len(args) { + return nil, fmt.Errorf("mismatched keys and arguments length: %d keys, %d arguments", len(keys), len(args)) + } + namedArgs := make([]NamedField, len(args)) + for i := range args { + namedArgs[i] = NamedField{ + Name: keys[i], + Value: cantonFieldValue(args[i]), + RawValue: args[i], + } + } + + return namedArgs, nil +} + +// cantonFieldValue is like getFieldValue but also converts map[string]any to StructField. +// Canton decoded args use map[string]any for nested Daml records (via toDisplayArg in the Canton +// decoder). This is Canton-scoped: other chains (e.g. TON) also return map[string]any in some +// decoded fields but rely on the default fmt.Sprintf("%v", ...) rendering via getFieldValue. +func cantonFieldValue(argument any) FieldValue { + if m, ok := argument.(map[string]any); ok { + return mapToStructField(m) + } + // For slices, recurse so nested maps within arrays are also converted. + // Recurse into slices/arrays so nested maps within them are also converted — but not []byte, + // which must fall through to getFieldValue so it renders as BytesField (hex-preview) rather + // than ArrayField with individual byte elements. + if _, isByteSlice := argument.([]byte); !isByteSlice { + if kind := reflect.TypeOf(argument); kind != nil { + if kind.Kind() == reflect.Array || kind.Kind() == reflect.Slice { + array := ArrayField{} + v := reflect.ValueOf(argument) + for i := range v.Len() { + array.Elements = append(array.Elements, cantonFieldValue(v.Index(i).Interface())) + } + + return array + } + } + } + + return getFieldValue(argument) +} + +// mapToStructField converts a map[string]any to a StructField with sorted keys. +func mapToStructField(m map[string]any) StructField { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + + fields := make([]NamedField, 0, len(m)) + for _, k := range keys { + fields = append(fields, NamedField{ + Name: k, + Value: cantonFieldValue(m[k]), + }) + } + + return StructField{Fields: fields} +} diff --git a/experimental/analyzer/canton_analyzer_test.go b/experimental/analyzer/canton_analyzer_test.go new file mode 100644 index 000000000..535f9ae60 --- /dev/null +++ b/experimental/analyzer/canton_analyzer_test.go @@ -0,0 +1,453 @@ +package analyzer + +import ( + "bytes" + "encoding/json" + "os" + "testing" + + "github.com/stretchr/testify/require" + + chainsel "github.com/smartcontractkit/chain-selectors" + core "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/core" + factory "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/factory" + chainlinkapi "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/chainlink/chainlinkapi" + cantontypes "github.com/smartcontractkit/go-daml/pkg/types" + "github.com/smartcontractkit/mcms" + mcmscantonsdk "github.com/smartcontractkit/mcms/sdk/canton" + "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" +) + +const cantonFactoryAddress = "0x0000000000000000000000000000000000000000000000000000000000000abc" + +// cantonOperationData returns the raw operation bytes for a generated choice-argument struct, as +// stored in tx.Data by the Canton MCMS encoder. go-daml's MarshalHex returns the raw encoded bytes +// as a string in the pinned version; decode first if a future version returns a hex string. +func cantonOperationData(t *testing.T, v interface{ MarshalHex() (string, error) }) []byte { + t.Helper() + s, err := v.MarshalHex() + require.NoError(t, err) + + return []byte(s) +} + +func TestAnalyzeCantonTransactions(t *testing.T) { + t.Parallel() + + chainSelector := chainsel.CANTON_TESTNET.Selector + defaultProposalCtx := &DefaultProposalContext{ + AddressesByChain: deployment.AddressesByChain{ + chainSelector: { + cantonFactoryAddress: deployment.MustTypeAndVersionFromString("CCIPFactory 0.1.0"), + }, + }, + } + + deployParams := factory.DeployRMNRemoteParams{ + InstanceId: "rmn-remote-1", + RmnOwner: "alice::abc123", + CcipOwner: "bob::def456", + CustomObservers: []cantontypes.PARTY{"carol::ghi789"}, + CursedSubjects: []cantontypes.TEXT{"0x01"}, + } + + tx := types.Transaction{ + To: cantonFactoryAddress, + Data: cantonOperationData(t, deployParams), + AdditionalFields: mustMarshalJSON(t, mcmscantonsdk.AdditionalFields{ + TargetInstanceAddress: "ccip-factory-1@alice::abc123", + FunctionName: "DeployRMNRemote", + TargetTemplateID: "#pkg:CCIP.Factory:CCIPFactory", + }), + } + + decoder := mcmscantonsdk.NewDecoder() + result, err := AnalyzeCantonTransaction(defaultProposalCtx, decoder, chainSelector, tx) + require.NoError(t, err) + require.NotNil(t, result) + + require.Equal(t, cantonFactoryAddress, result.Address) + require.Equal(t, "CCIPFactory::DeployRMNRemote", result.Method) + require.Equal(t, "CCIPFactory", result.ContractType) + + require.Len(t, result.Inputs, 5) + require.Equal(t, "instanceId", result.Inputs[0].Name) + require.Equal(t, SimpleField{Value: "rmn-remote-1"}, result.Inputs[0].Value) + require.Equal(t, "rmnOwner", result.Inputs[1].Name) + require.Equal(t, SimpleField{Value: "alice::abc123"}, result.Inputs[1].Value) + require.Equal(t, "ccipOwner", result.Inputs[2].Name) + require.Equal(t, "customObservers", result.Inputs[3].Name) + require.Equal(t, "ArrayField", result.Inputs[3].Value.GetType()) +} + +// TestAnalyzeCantonTransaction_DecodeErrorIsNonFatal verifies an undecodable operation surfaces the +// error in the Method field rather than failing the whole proposal. +func TestAnalyzeCantonTransaction_DecodeErrorIsNonFatal(t *testing.T) { + t.Parallel() + + chainSelector := chainsel.CANTON_TESTNET.Selector + ctx := &DefaultProposalContext{AddressesByChain: deployment.AddressesByChain{}} + + tx := types.Transaction{ + To: cantonFactoryAddress, + Data: []byte{0x01, 0x02, 0x03}, + AdditionalFields: mustMarshalJSON(t, mcmscantonsdk.AdditionalFields{ + TargetInstanceAddress: "x@alice::abc", + FunctionName: "NotARealChoice", + TargetTemplateID: "#pkg:CCIP.RMNRemote:RMNRemote", + }), + } + + result, err := AnalyzeCantonTransaction(ctx, mcmscantonsdk.NewDecoder(), chainSelector, tx) + require.NoError(t, err) + require.NotNil(t, result) + require.Contains(t, result.Method, "NotARealChoice") +} + +// TestBuildTimelockReport_Canton exercises the full path: a Canton timelock proposal flows through +// the family dispatcher in analyzeTransactions and produces a decoded call (no longer empty). +func TestBuildTimelockReport_Canton(t *testing.T) { + t.Parallel() + + chainSelector := chainsel.CANTON_TESTNET.Selector + proposalCtx := &DefaultProposalContext{ + AddressesByChain: deployment.AddressesByChain{ + chainSelector: { + cantonFactoryAddress: deployment.MustTypeAndVersionFromString("CCIPFactory 0.1.0"), + }, + }, + renderer: NewMarkdownRenderer(), + } + + deployParams := factory.DeployRMNRemoteParams{ + InstanceId: "rmn-remote-1", + RmnOwner: "alice::abc123", + CcipOwner: "bob::def456", + } + + proposal := &mcms.TimelockProposal{ + Operations: []types.BatchOperation{ + { + ChainSelector: types.ChainSelector(chainSelector), + Transactions: []types.Transaction{ + { + To: cantonFactoryAddress, + Data: cantonOperationData(t, deployParams), + AdditionalFields: mustMarshalJSON(t, mcmscantonsdk.AdditionalFields{ + TargetInstanceAddress: "ccip-factory-1@alice::abc123", + FunctionName: "DeployRMNRemote", + TargetTemplateID: "#pkg:CCIP.Factory:CCIPFactory", + }), + }, + }, + }, + }, + } + + report, err := BuildTimelockReport(t.Context(), proposalCtx, deployment.Environment{}, proposal) + require.NoError(t, err) + require.Len(t, report.Batches, 1) + require.Len(t, report.Batches[0].Operations, 1) + + calls := report.Batches[0].Operations[0].Calls + require.Len(t, calls, 1) + require.Equal(t, "CCIPFactory::DeployRMNRemote", calls[0].Method) + require.Equal(t, "CCIPFactory", calls[0].ContractType) +} + +// proposal JSON -> mcms.NewTimelockProposal -> BuildTimelockReport (family dispatch + Canton decode) +// -> DescribeTimelockProposal (Markdown render, i.e. the .decoded.md a reviewer sees) +// +// on real GlobalConfig / Executor / FeeQuoter operationData. +func TestBuildTimelockReport_CantonTestProposal(t *testing.T) { + t.Parallel() + + data, err := os.ReadFile("testdata/canton_test_proposal.json") + require.NoError(t, err) + + // Parse exactly as the product does (engine/cld/mcms/proposalanalysis/decoder). + proposal, err := mcms.NewTimelockProposal(bytes.NewReader(data)) + require.NoError(t, err, "fixture must be a valid MCMS TimelockProposal") + + proposalCtx := &DefaultProposalContext{ + AddressesByChain: deployment.AddressesByChain{}, + renderer: NewMarkdownRenderer(), + } + + // 1) Decode: build the report and check every tx decoded to its Daml choice + // (MethodName is ContractEntity::Choice) — no decode errors in any Method. + report, err := BuildTimelockReport(t.Context(), proposalCtx, deployment.Environment{}, proposal) + require.NoError(t, err) + + wantMethods := []string{ + "GlobalConfig::ApplyDestChainConfigUpdates", + "Executor::ApplyDestChainUpdates", + "FeeQuoter::ApplyFeeQuoterDestChainConfigUpdates", + "FeeQuoter::ApplyPriceUpdatersUpdate", + "FeeQuoter::UpdatePrices", + "GlobalConfig::ApplySourceChainConfigUpdates", + } + + var methods []string + for _, batch := range report.Batches { + for _, op := range batch.Operations { + for _, call := range op.Calls { + methods = append(methods, call.Method) + } + } + } + require.ElementsMatch(t, wantMethods, methods) + + // 2) Render: produce the Markdown a reviewer sees (the .decoded.md content) and confirm each + // decoded call appears in it. This covers the full proposal -> decode -> render pipeline. + md, err := DescribeTimelockProposal(t.Context(), proposalCtx, deployment.Environment{}, proposal) + require.NoError(t, err) + require.NotEmpty(t, md) + for _, m := range wantMethods { + require.Contains(t, md, m, "rendered markdown should contain decoded call %q", m) + } +} + +// TestAnalyzeCantonTransactions_PerField is a table-driven test for AnalyzeCantonTransaction that +// covers four distinct structural shapes of Canton operationData: +// +// - Single scalar field: a Daml type alias (NUMERIC) decodes to a SimpleField. +// - Scalars + slices: TEXT/PARTY scalars become SimpleField; []PARTY/[]TEXT become ArrayField. +// Exact string values are asserted to confirm toDisplayArg strips Daml type aliases. +// - Array of nested struct: []SourceChainConfigArgs (each element has scalars, slices, and a +// sub-struct) exercises the toDisplayArg recursion through nested Daml records → ArrayField. +// - Two slice fields: both populated and empty slices decode to ArrayField. +// +// Each case asserts the decoded Method (ContractEntity::FunctionName), input count, each input +// Name, and each input FieldValue type. Deep contents of nested fields are not asserted here — +// those are covered by TestBuildTimelockReport_CantonTestProposal on real proposal bytes. +func TestAnalyzeCantonTransactions_PerField(t *testing.T) { + t.Parallel() + + const ( + globalConfigAddr = "0x0000000000000000000000000000000000000000000000000000000000000001" + feeQuoterAddr = "0x0000000000000000000000000000000000000000000000000000000000000002" + ) + chainSelector := chainsel.CANTON_TESTNET.Selector + ctx := &DefaultProposalContext{AddressesByChain: deployment.AddressesByChain{}} + decoder := mcmscantonsdk.NewDecoder() + + type inputCheck struct { + name string + fieldType string // FieldValue.GetType() + } + + tests := []struct { + name string + tx types.Transaction + method string + inputs []inputCheck + }{ + { + // Simple single-field scalar: NUMERIC("1234") → string "1234" → SimpleField + name: "IsCursedForChain — single NUMERIC field", + tx: types.Transaction{ + To: globalConfigAddr, + Data: cantonOperationData(t, core.IsCursedForChainMCMSParams{ChainSelector: "1234"}), + AdditionalFields: mustMarshalJSON(t, mcmscantonsdk.AdditionalFields{ + TargetInstanceAddress: "rmn-remote-1@alice::abc", + FunctionName: "IsCursedForChain", + TargetTemplateID: "#pkg:CCIP.RMNRemote:RMNRemote", + }), + }, + method: "RMNRemote::IsCursedForChain", + inputs: []inputCheck{ + {name: "chainSelector", fieldType: "SimpleField"}, + }, + }, + { + // Deploy: TEXT/PARTY scalars + slice fields + name: "DeployRMNRemote — scalars and slices", + tx: types.Transaction{ + To: globalConfigAddr, + Data: cantonOperationData(t, factory.DeployRMNRemoteParams{ + InstanceId: "rmn-1", + RmnOwner: "alice::abc", + CcipOwner: "bob::def", + CustomObservers: []cantontypes.PARTY{"carol::ghi"}, + CursedSubjects: []cantontypes.TEXT{"0x01"}, + }), + AdditionalFields: mustMarshalJSON(t, mcmscantonsdk.AdditionalFields{ + TargetInstanceAddress: "factory-1@alice::abc", + FunctionName: "DeployRMNRemote", + TargetTemplateID: "#pkg:CCIP.Factory:CCIPFactory", + }), + }, + method: "CCIPFactory::DeployRMNRemote", + inputs: []inputCheck{ + {name: "instanceId", fieldType: "SimpleField"}, + {name: "rmnOwner", fieldType: "SimpleField"}, + {name: "ccipOwner", fieldType: "SimpleField"}, + {name: "customObservers", fieldType: "ArrayField"}, + {name: "cursedSubjects", fieldType: "ArrayField"}, + }, + }, + { + // Nested struct: ApplySourceChainConfigUpdates wraps a []SourceChainConfigArgs, + // which includes a NUMERIC, BOOL, []TEXT, and []RawInstanceAddress (struct) fields — + // exercises the array-of-nested-struct path without requiring binary raw-bytes fields. + name: "ApplySourceChainConfigUpdates — array of nested struct", + tx: types.Transaction{ + To: globalConfigAddr, + Data: cantonOperationData(t, core.ApplySourceChainConfigUpdatesParams{ + SourceChainConfigArgs: []core.SourceChainConfigArgs{ + { + SourceChainSelector: cantontypes.NUMERIC("16015286601757825753"), + IsEnabled: cantontypes.BOOL(true), + OnRampAddresses: []cantontypes.TEXT{"0xdeadbeef"}, + DefaultCCVs: []chainlinkapi.RawInstanceAddress{ + {Unpack: cantontypes.TEXT("ccv-1@alice::abc")}, + }, + LaneMandatedCCVs: []chainlinkapi.RawInstanceAddress{}, + }, + }, + }), + AdditionalFields: mustMarshalJSON(t, mcmscantonsdk.AdditionalFields{ + TargetInstanceAddress: "globalconfig-1@alice::abc", + FunctionName: "ApplySourceChainConfigUpdates", + TargetTemplateID: "#pkg:CCIP.GlobalConfig:GlobalConfig", + }), + }, + method: "GlobalConfig::ApplySourceChainConfigUpdates", + inputs: []inputCheck{ + {name: "sourceChainConfigArgs", fieldType: "ArrayField"}, + }, + }, + { + // Two scalar slice fields — verify both are decoded + name: "ApplyPriceUpdatersUpdate — two slice fields", + tx: types.Transaction{ + To: feeQuoterAddr, + Data: cantonOperationData(t, core.ApplyPriceUpdatersUpdateParams{ + AddedPriceUpdaters: []cantontypes.PARTY{"alice::abc"}, + RemovedPriceUpdaters: []cantontypes.PARTY{}, + }), + AdditionalFields: mustMarshalJSON(t, mcmscantonsdk.AdditionalFields{ + TargetInstanceAddress: "feequoter-1@alice::abc", + FunctionName: "ApplyPriceUpdatersUpdate", + TargetTemplateID: "#pkg:CCIP.FeeQuoter:FeeQuoter", + }), + }, + method: "FeeQuoter::ApplyPriceUpdatersUpdate", + inputs: []inputCheck{ + {name: "addedPriceUpdaters", fieldType: "ArrayField"}, + {name: "removedPriceUpdaters", fieldType: "ArrayField"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := AnalyzeCantonTransaction(ctx, decoder, chainSelector, tt.tx) + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, tt.method, result.Method) + require.Len(t, result.Inputs, len(tt.inputs), "input count mismatch for %s", tt.method) + + for i, want := range tt.inputs { + require.Equal(t, want.name, result.Inputs[i].Name, "input[%d] name", i) + require.Equal(t, want.fieldType, result.Inputs[i].Value.GetType(), + "input[%d] %q type", i, want.name) + } + }) + } +} + +func TestAnalyzeCantonTransaction_ErrorCases(t *testing.T) { + t.Parallel() + + chainSelector := chainsel.CANTON_TESTNET.Selector + ctx := &DefaultProposalContext{AddressesByChain: deployment.AddressesByChain{}} + decoder := mcmscantonsdk.NewDecoder() + + tests := []struct { + name string + tx types.Transaction + wantHardErr bool // expect err != nil from AnalyzeCantonTransaction + wantMethodContains string // non-fatal: expect error surfaced in Method field + }{ + { + name: "invalid AdditionalFields JSON — hard error", + tx: types.Transaction{ + To: "0xaddr", + Data: []byte{0x01}, + AdditionalFields: json.RawMessage(`invalid json`), + }, + wantHardErr: true, + }, + { + name: "unknown choice on known contract — non-fatal, choice name in Method", + tx: types.Transaction{ + To: "0xaddr", + Data: []byte{0x01, 0x02}, + AdditionalFields: mustMarshalJSON(t, mcmscantonsdk.AdditionalFields{ + TargetInstanceAddress: "x@alice::abc", + FunctionName: "NotARealChoice", + TargetTemplateID: "#pkg:CCIP.RMNRemote:RMNRemote", + }), + }, + wantMethodContains: "NotARealChoice", + }, + { + name: "version-skew — correct choice name, bytes from an older binding — non-fatal", + // operationData that decodes no current binding type (round-trips against none) + tx: types.Transaction{ + To: "0xaddr", + Data: []byte{0x0e, 0x65, 0x78, 0x65, 0x63, 0x75, 0x74, 0x6f, 0x72}, + AdditionalFields: mustMarshalJSON(t, mcmscantonsdk.AdditionalFields{ + TargetInstanceAddress: "factory-1@alice::abc", + FunctionName: "DeployExecutor", + TargetTemplateID: "#pkg:CCIP.Factory:CCIPFactory", + }), + }, + wantMethodContains: "DeployExecutor", + }, + { + name: "empty Data — non-fatal, function name surfaced in Method", + tx: types.Transaction{ + To: "0xaddr", + Data: []byte{}, + AdditionalFields: mustMarshalJSON(t, mcmscantonsdk.AdditionalFields{ + TargetInstanceAddress: "globalconfig-1@alice::abc", + FunctionName: "ApplyDestChainConfigUpdates", + TargetTemplateID: "#pkg:CCIP.GlobalConfig:GlobalConfig", + }), + }, + wantMethodContains: "ApplyDestChainConfigUpdates", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + result, err := AnalyzeCantonTransaction(ctx, decoder, chainSelector, tt.tx) + + if tt.wantHardErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + require.Contains(t, result.Method, tt.wantMethodContains) + }) + } +} + +func mustMarshalJSON(t *testing.T, v any) json.RawMessage { + t.Helper() + b, err := json.Marshal(v) + require.NoError(t, err) + + return b +} diff --git a/experimental/analyzer/renderer_markdown.go b/experimental/analyzer/renderer_markdown.go index 5c5a9c78b..1950cbdbc 100644 --- a/experimental/analyzer/renderer_markdown.go +++ b/experimental/analyzer/renderer_markdown.go @@ -722,7 +722,13 @@ func compactValue(field FieldValue, ctx *FieldContext) string { case SimpleField: return truncateMiddle(f.GetValue(), MaxCompactValueLength) case StructField: - return "struct" + if len(f.Fields) == 0 { + return "{}" + } + // Show the first field as a hint: {key: value…} + first := f.Fields[0] + + return truncateMiddle(fmt.Sprintf("{%s: %s}", first.Name, compactValue(first.Value, ctx)), MaxCompactValueLength) case ArrayField: return fmt.Sprintf("array[%d]", f.GetLength()) default: diff --git a/experimental/analyzer/renderer_markdown_test.go b/experimental/analyzer/renderer_markdown_test.go index 7ff5152c8..af989dffc 100644 --- a/experimental/analyzer/renderer_markdown_test.go +++ b/experimental/analyzer/renderer_markdown_test.go @@ -416,8 +416,10 @@ func TestMarkdownRenderer_ArrayField_WithStructElement(t *testing.T) { summary, details := renderer.summarizeField("chainConfigAdds", arrayField, ctx) - // Summary should show array with struct - assert.Contains(t, summary, "array[1]: [struct]") + // Summary shows array with struct — compact preview uses {firstField: value…} hint, not "[struct]" + assert.Contains(t, summary, "array[1]:") + assert.Contains(t, summary, "[{") + assert.NotContains(t, summary, "[struct]") // Details should show FULL nested content in GitHub Flavored Markdown collapsible section assert.Contains(t, details, "
") @@ -439,8 +441,8 @@ func TestMarkdownRenderer_ArrayField_WithStructElement(t *testing.T) { assert.Contains(t, details, "```") assert.Contains(t, details, "
") - // Details should NOT contain just the summary - assert.NotContains(t, details, "array[1]: [struct]") + // Details should not just repeat the inline summary + assert.NotContains(t, details, summary) } func TestMarkdownRenderer_StructField_EmptyFields(t *testing.T) { @@ -591,7 +593,7 @@ func TestCompactValue(t *testing.T) { assert.Contains(t, compactValue(ChainSelectorField{Value: 1}, ctx), "1") assert.Contains(t, compactValue(BytesField{Value: []byte{0x01, 0x02}}, ctx), "0x0102") assert.Equal(t, "short", compactValue(SimpleField{Value: "short"}, ctx)) - assert.Equal(t, "struct", compactValue(StructField{Fields: []NamedField{}}, ctx)) + assert.Equal(t, "{}", compactValue(StructField{Fields: []NamedField{}}, ctx)) assert.Contains(t, compactValue(ArrayField{Elements: []FieldValue{}}, ctx), "array[0]") } diff --git a/experimental/analyzer/report_builder.go b/experimental/analyzer/report_builder.go index 52ac18960..7d422e983 100644 --- a/experimental/analyzer/report_builder.go +++ b/experimental/analyzer/report_builder.go @@ -92,6 +92,8 @@ func analyzeTransactions(ctx context.Context, proposalCtx ProposalContext, env d return AnalyzeSuiTransactions(proposalCtx, chainSel, txs) case chainsel.FamilyTon: return AnalyzeTONTransactions(proposalCtx, chainSel, txs) + case chainsel.FamilyCanton: + return AnalyzeCantonTransactions(proposalCtx, chainSel, txs) default: return []*DecodedCall{}, nil } diff --git a/experimental/analyzer/testdata/canton_test_proposal.json b/experimental/analyzer/testdata/canton_test_proposal.json new file mode 100644 index 000000000..0c45e5fd2 --- /dev/null +++ b/experimental/analyzer/testdata/canton_test_proposal.json @@ -0,0 +1,770 @@ +{ + "version": "v1", + "kind": "TimelockProposal", + "validUntil": 4070908800, + "signatures": [ + { + "R": "0xc71b31550152f03e50357e7d6453b9765b879be658c21e8f2670a7539510ef71", + "S": "0x13da381b8fe063d69a9e412370014f91219c6d1fdc7188049f7c7da9a63b493d", + "V": 1 + } + ], + "overridePreviousRoot": true, + "chainMetadata": { + "8706591216959472610": { + "startingOpCount": 1, + "mcmAddress": "0xf2919888cc1303107e124efa6ebc00348528363bccb9ce8c19a2de9637a6215d", + "additionalFields": { + "chainId": 1, + "multisigId": "mcms-ccip@participant1-localparty-1::1220f11b1bc6a88b6504067e8accc7b841303dac1146c0b549b7b87a08fded2266f6-proposer", + "instanceId": "mcms-ccip" + } + } + }, + "description": "Local devnet \u2014 Configure Canton \u2194 Sepolia CCIP lanes (Canton side)", + "metadata": { + "changesets": [ + { + "config": { + "Chains": [ + { + "ChainSelector": 8706591216959472610, + "CommitteeVerifiers": null, + "FamilyExtras": null, + "RemoteChains": { + "16015286601757825753": { + "AllowTrafficFrom": null, + "BaseExecutionGasCost": null, + "DefaultExecutorQualifier": "", + "DefaultInboundCCVs": [ + { + "address": "", + "chainSelector": 0, + "labels": [], + "qualifier": "default", + "type": "CommitteeVerifier", + "version": "0.1.0" + } + ], + "DefaultOutboundCCVs": [ + { + "address": "", + "chainSelector": 0, + "labels": [], + "qualifier": "default", + "type": "CommitteeVerifier", + "version": "0.1.0" + } + ], + "ExecutorDestChainConfig": null, + "FeeQuoterDestChainConfig": { + "DefaultTokenDestGasOverhead": null, + "DefaultTokenFeeUSDCents": null, + "DefaultTxGasLimit": null, + "DestGasPerPayloadByteBase": null, + "IsEnabled": null, + "LinkFeeMultiplierPercent": 100, + "MaxDataBytes": null, + "MaxPerMsgGasLimit": null, + "NetworkFeeUSDCents": null, + "OverrideExistingConfig": false, + "USDPerUnitGas": 38 + }, + "LaneMandatedInboundCCVs": null, + "LaneMandatedOutboundCCVs": null, + "MessageNetworkFeeUSDCents": null, + "TokenNetworkFeeUSDCents": null, + "TokenReceiverAllowed": null + } + } + } + ], + "MCMS": { + "Description": "Local devnet \u2014 Configure Canton \u2194 Sepolia CCIP lanes (Canton side)", + "OverridePreviousRoot": true, + "Qualifier": "ccipOwner", + "TimelockAction": "schedule", + "TimelockDelay": "1s", + "ValidUntil": 1780700211 + }, + "Topology": { + "ExecutorPools": { + "default": { + "BackoffDuration": 15000000000, + "ChainConfigs": { + "10344971235874465080": { + "ExecutionInterval": 15000000000, + "NOPAliases": [ + "chainlink-ccv-staging-0", + "chainlink-ccv-staging-1", + "chainlink-ccv-staging-2", + "chainlink-ccv-staging-3", + "chainlink-ccv-staging-4", + "chainlink-ccv-staging-5", + "chainlink-ccv-staging-6", + "chainlink-ccv-staging-7", + "chainlink-ccv-staging-8", + "chainlink-ccv-staging-9", + "chainlink-ccv-staging-10", + "chainlink-ccv-staging-11", + "chainlink-ccv-staging-12", + "chainlink-ccv-staging-13", + "chainlink-ccv-staging-14", + "chainlink-ccv-staging-15", + "chainlink-ccv-staging-16", + "chainlink-ccv-staging-17", + "chainlink-ccv-staging-18", + "chainlink-ccv-staging-19", + "chainlink-ccv-staging-20" + ] + }, + "14767482510784806043": { + "ExecutionInterval": 15000000000, + "NOPAliases": [ + "chainlink-ccv-staging-0", + "chainlink-ccv-staging-1", + "chainlink-ccv-staging-2", + "chainlink-ccv-staging-3", + "chainlink-ccv-staging-4", + "chainlink-ccv-staging-5", + "chainlink-ccv-staging-6", + "chainlink-ccv-staging-7", + "chainlink-ccv-staging-8", + "chainlink-ccv-staging-9", + "chainlink-ccv-staging-10", + "chainlink-ccv-staging-11", + "chainlink-ccv-staging-12", + "chainlink-ccv-staging-13", + "chainlink-ccv-staging-14", + "chainlink-ccv-staging-15", + "chainlink-ccv-staging-16", + "chainlink-ccv-staging-17", + "chainlink-ccv-staging-18", + "chainlink-ccv-staging-19", + "chainlink-ccv-staging-20" + ] + }, + "16015286601757825753": { + "ExecutionInterval": 15000000000, + "NOPAliases": [ + "chainlink-ccv-staging-0", + "chainlink-ccv-staging-1", + "chainlink-ccv-staging-2", + "chainlink-ccv-staging-3", + "chainlink-ccv-staging-4", + "chainlink-ccv-staging-5", + "chainlink-ccv-staging-6", + "chainlink-ccv-staging-7", + "chainlink-ccv-staging-8", + "chainlink-ccv-staging-9", + "chainlink-ccv-staging-10", + "chainlink-ccv-staging-11", + "chainlink-ccv-staging-12", + "chainlink-ccv-staging-13", + "chainlink-ccv-staging-14", + "chainlink-ccv-staging-15", + "chainlink-ccv-staging-16", + "chainlink-ccv-staging-17", + "chainlink-ccv-staging-18", + "chainlink-ccv-staging-19", + "chainlink-ccv-staging-20" + ] + }, + "16281711391670634445": { + "ExecutionInterval": 15000000000, + "NOPAliases": [ + "chainlink-ccv-staging-0", + "chainlink-ccv-staging-1", + "chainlink-ccv-staging-2", + "chainlink-ccv-staging-3", + "chainlink-ccv-staging-4", + "chainlink-ccv-staging-5", + "chainlink-ccv-staging-6", + "chainlink-ccv-staging-7", + "chainlink-ccv-staging-8", + "chainlink-ccv-staging-9", + "chainlink-ccv-staging-10", + "chainlink-ccv-staging-11", + "chainlink-ccv-staging-12", + "chainlink-ccv-staging-13", + "chainlink-ccv-staging-14", + "chainlink-ccv-staging-15", + "chainlink-ccv-staging-16", + "chainlink-ccv-staging-17", + "chainlink-ccv-staging-18", + "chainlink-ccv-staging-19", + "chainlink-ccv-staging-20" + ] + }, + "3478487238524512106": { + "ExecutionInterval": 15000000000, + "NOPAliases": [ + "chainlink-ccv-staging-0", + "chainlink-ccv-staging-1", + "chainlink-ccv-staging-2", + "chainlink-ccv-staging-3", + "chainlink-ccv-staging-4", + "chainlink-ccv-staging-5", + "chainlink-ccv-staging-6", + "chainlink-ccv-staging-7", + "chainlink-ccv-staging-8", + "chainlink-ccv-staging-9", + "chainlink-ccv-staging-10", + "chainlink-ccv-staging-11", + "chainlink-ccv-staging-12", + "chainlink-ccv-staging-13", + "chainlink-ccv-staging-14", + "chainlink-ccv-staging-15", + "chainlink-ccv-staging-16", + "chainlink-ccv-staging-17", + "chainlink-ccv-staging-18", + "chainlink-ccv-staging-19", + "chainlink-ccv-staging-20" + ] + } + }, + "IndexerQueryLimit": 100, + "LookbackWindow": 3600000000000, + "MaxRetryDuration": 28800000000000, + "NtpServer": "time.google.com", + "ReaderCacheExpiry": 300000000000, + "WorkerCount": 100 + } + }, + "IndexerAddress": [ + "https://indexer-1.example.test", + "https://indexer-2.example.test" + ], + "Monitoring": { + "Beholder": { + "CACertFile": "", + "InsecureConnection": true, + "LogStreamingEnabled": false, + "MetricReaderInterval": 5, + "OtelExporterGRPCEndpoint": "", + "OtelExporterHTTPEndpoint": "telemetry.example.test:443", + "TraceBatchTimeout": 10, + "TraceSampleRatio": 1 + }, + "Enabled": true, + "Type": "beholder" + }, + "NOPTopology": { + "Committees": { + "default": { + "Aggregators": [ + { + "Address": "aggregator-1.example.test", + "InsecureAggregatorConnection": false, + "Name": "aggregator-1" + }, + { + "Address": "aggregator-2.example.test", + "InsecureAggregatorConnection": false, + "Name": "aggregator-2" + } + ], + "ChainConfigs": { + "10109143320554840099": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-canton-committee-verifier", + "chainlink-canton-committee-verifier-0" + ], + "Threshold": 2 + }, + "10344971235874465080": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-ccv-staging-0", + "chainlink-ccv-staging-1", + "chainlink-ccv-staging-5", + "chainlink-ccv-staging-6", + "chainlink-ccv-staging-10", + "chainlink-ccv-staging-13", + "chainlink-ccv-staging-14", + "chainlink-ccv-staging-15", + "chainlink-ccv-staging-17", + "chainlink-ccv-staging-18" + ], + "Threshold": 6 + }, + "14767482510784806043": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-ccv-staging-0", + "chainlink-ccv-staging-1", + "chainlink-ccv-staging-2", + "chainlink-ccv-staging-3", + "chainlink-ccv-staging-4", + "chainlink-ccv-staging-10", + "chainlink-ccv-staging-11", + "chainlink-ccv-staging-12", + "chainlink-ccv-staging-13", + "chainlink-ccv-staging-14" + ], + "Threshold": 6 + }, + "16015286601757825753": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-ccv-staging-0", + "chainlink-ccv-staging-1", + "chainlink-ccv-staging-2", + "chainlink-ccv-staging-3", + "chainlink-ccv-staging-4", + "chainlink-ccv-staging-5", + "chainlink-ccv-staging-6", + "chainlink-ccv-staging-7", + "chainlink-ccv-staging-8", + "chainlink-ccv-staging-9" + ], + "Threshold": 6 + }, + "16281711391670634445": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-ccv-staging-2", + "chainlink-ccv-staging-3", + "chainlink-ccv-staging-4", + "chainlink-ccv-staging-7", + "chainlink-ccv-staging-8", + "chainlink-ccv-staging-11", + "chainlink-ccv-staging-16", + "chainlink-ccv-staging-17", + "chainlink-ccv-staging-19", + "chainlink-ccv-staging-20" + ], + "Threshold": 6 + }, + "3478487238524512106": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-ccv-staging-5", + "chainlink-ccv-staging-6", + "chainlink-ccv-staging-7", + "chainlink-ccv-staging-8", + "chainlink-ccv-staging-9", + "chainlink-ccv-staging-10", + "chainlink-ccv-staging-11", + "chainlink-ccv-staging-12", + "chainlink-ccv-staging-15", + "chainlink-ccv-staging-16" + ], + "Threshold": 6 + }, + "8706591216959472610": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-canton-committee-verifier", + "chainlink-canton-committee-verifier-0" + ], + "Threshold": 2 + } + }, + "Qualifier": "default", + "StorageLocations": [ + "" + ], + "VerifierVersion": "2.0.0" + }, + "secondary": { + "Aggregators": [ + { + "Address": "secondary-aggregator-1.example.test", + "InsecureAggregatorConnection": false, + "Name": "secondary-aggregator-1" + }, + { + "Address": "secondary-aggregator-2.example.test", + "InsecureAggregatorConnection": false, + "Name": "secondary-aggregator-2" + } + ], + "ChainConfigs": { + "10344971235874465080": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-ccv-staging-0" + ], + "Threshold": 1 + }, + "14767482510784806043": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-ccv-staging-0" + ], + "Threshold": 1 + }, + "16015286601757825753": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-ccv-staging-0" + ], + "Threshold": 1 + }, + "16281711391670634445": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-ccv-staging-0" + ], + "Threshold": 1 + }, + "3478487238524512106": { + "AllowlistAdmin": "0x0000000000000000000000000000000000000000", + "FeeAggregator": "0xf5008EAf49Ea2B8B905f23B45e7206E191B0959e", + "NOPAliases": [ + "chainlink-ccv-staging-0" + ], + "Threshold": 1 + } + }, + "Qualifier": "secondary", + "StorageLocations": [ + "" + ], + "VerifierVersion": "2.0.0" + } + }, + "NOPs": [ + { + "Alias": "chainlink-ccv-staging-0", + "Mode": "", + "Name": "chainlink-ccv-staging-0", + "SignerAddressByFamily": { + "canton": "04b3a22e521d90ece86e60599a26217f7ead38c1eb3fc68000fdc4b6e5cce59eed54a886af05f22e60ec044ede68505736b496e1a03b2aedf6ab366dc84b477bce", + "evm": "0xedadd85f6d13c23817e935da32494f200971ab6d" + } + }, + { + "Alias": "chainlink-ccv-staging-1", + "Mode": "", + "Name": "chainlink-ccv-staging-1", + "SignerAddressByFamily": { + "canton": "04e7db13a109f40c8d4590942d31729c4047ecd9b67c2043e391c6d9ef93001e12c0f01452e4e5f7743c45fad0be9c5b6cec1abd4006dc9b796511b8799fcb040f", + "evm": "0x96a6c9439db276d168ac9f9287deccec63acc581" + } + }, + { + "Alias": "chainlink-ccv-staging-2", + "Mode": "", + "Name": "chainlink-ccv-staging-2", + "SignerAddressByFamily": { + "canton": "049ed0bdc13dd97814281a101ffad09f3eeac2a4a9cdba575602dfebc46bf96ceda8445456c38ab8b10c7da8c000c1940f6c1fddd09982ad19faed00d75344db26", + "evm": "0xedadd85f6d13c23817e935da32494f200971ab6d" + } + }, + { + "Alias": "chainlink-ccv-staging-3", + "Mode": "", + "Name": "chainlink-ccv-staging-3", + "SignerAddressByFamily": { + "canton": "04891e2cb3d05b820753e02263eb2298d8bfc47e4a5f44101c59ad5baaaf7602e5086f68129cab83fe9e98a06606e1d6379f5c471a7e6c8cd189a322fcfac948d0", + "evm": "0x96a6c9439db276d168ac9f9287deccec63acc581" + } + }, + { + "Alias": "chainlink-ccv-staging-4", + "Mode": "", + "Name": "chainlink-ccv-staging-4", + "SignerAddressByFamily": { + "canton": "04270666c147dc39b996167374d8919e2b369416aed5a0ce0c55615465ced27154a8d2932396702607637d817be5563b1900cf7b007a04d2de8d3e827407b3bb48", + "evm": "0xedadd85f6d13c23817e935da32494f200971ab6d" + } + }, + { + "Alias": "chainlink-ccv-staging-5", + "Mode": "", + "Name": "chainlink-ccv-staging-5", + "SignerAddressByFamily": { + "canton": "04c206cede148677b31c0ac78ed426ace6dce528ab52f5039ca93bc8b0543d94eb4d5add452a9b695c2d18db30fa72c1bb9d2328a62106ae631f378e8d5250614a", + "evm": "0x96a6c9439db276d168ac9f9287deccec63acc581" + } + }, + { + "Alias": "chainlink-ccv-staging-6", + "Mode": "", + "Name": "chainlink-ccv-staging-6", + "SignerAddressByFamily": { + "canton": "04ee145636c0d6b728e313e009e0c3f5534446431506498847643944492b3f53f498034efb228cb2ba15434b4455c163d9dc63dc0b8376d75bd54462b7b3911551", + "evm": "0xedadd85f6d13c23817e935da32494f200971ab6d" + } + }, + { + "Alias": "chainlink-ccv-staging-7", + "Mode": "", + "Name": "chainlink-ccv-staging-7", + "SignerAddressByFamily": { + "canton": "04b0497672fdccd10b9a2b541cbd7fe7d7013ae2d97961d3072cd9bd4c49c74f487d8254615068d967b92b1f2cc836cdc69558d09a3f87cecba3b68cb230309b41", + "evm": "0x96a6c9439db276d168ac9f9287deccec63acc581" + } + }, + { + "Alias": "chainlink-ccv-staging-8", + "Mode": "", + "Name": "chainlink-ccv-staging-8", + "SignerAddressByFamily": { + "canton": "04ffd46bf90c2bf034d4af36f62c7af34fb0420ed6d16461264efa98eb44cc6175117a99211f6bf452bffbadd444062c261cc467726d08889269d3fadb7cb9acad", + "evm": "0xedadd85f6d13c23817e935da32494f200971ab6d" + } + }, + { + "Alias": "chainlink-ccv-staging-9", + "Mode": "", + "Name": "chainlink-ccv-staging-9", + "SignerAddressByFamily": { + "canton": "04cbeeddd1cfeb96ff0d414c192dc16c2bb31fc201e9017992b19485fd22a8587bcd3ae0391cae51ea8cd14c3a48185995fdee893fe954b33371b1d3a40f7baba3", + "evm": "0x96a6c9439db276d168ac9f9287deccec63acc581" + } + }, + { + "Alias": "chainlink-ccv-staging-10", + "Mode": "", + "Name": "chainlink-ccv-staging-10", + "SignerAddressByFamily": null + }, + { + "Alias": "chainlink-ccv-staging-11", + "Mode": "", + "Name": "chainlink-ccv-staging-11", + "SignerAddressByFamily": null + }, + { + "Alias": "chainlink-ccv-staging-12", + "Mode": "", + "Name": "chainlink-ccv-staging-12", + "SignerAddressByFamily": null + }, + { + "Alias": "chainlink-ccv-staging-13", + "Mode": "", + "Name": "chainlink-ccv-staging-13", + "SignerAddressByFamily": null + }, + { + "Alias": "chainlink-ccv-staging-14", + "Mode": "", + "Name": "chainlink-ccv-staging-14", + "SignerAddressByFamily": null + }, + { + "Alias": "chainlink-ccv-staging-15", + "Mode": "", + "Name": "chainlink-ccv-staging-15", + "SignerAddressByFamily": null + }, + { + "Alias": "chainlink-ccv-staging-16", + "Mode": "", + "Name": "chainlink-ccv-staging-16", + "SignerAddressByFamily": null + }, + { + "Alias": "chainlink-ccv-staging-17", + "Mode": "", + "Name": "chainlink-ccv-staging-17", + "SignerAddressByFamily": null + }, + { + "Alias": "chainlink-ccv-staging-18", + "Mode": "", + "Name": "chainlink-ccv-staging-18", + "SignerAddressByFamily": null + }, + { + "Alias": "chainlink-ccv-staging-19", + "Mode": "", + "Name": "chainlink-ccv-staging-19", + "SignerAddressByFamily": null + }, + { + "Alias": "chainlink-ccv-staging-20", + "Mode": "", + "Name": "chainlink-ccv-staging-20", + "SignerAddressByFamily": null + }, + { + "Alias": "chainlink-canton-committee-verifier", + "Mode": "standalone", + "Name": "chainlink-canton-committee-verifier", + "SignerAddressByFamily": { + "canton": "04b3a22e521d90ece86e60599a26217f7ead38c1eb3fc68000fdc4b6e5cce59eed54a886af05f22e60ec044ede68505736b496e1a03b2aedf6ab366dc84b477bce", + "evm": "0x96a6c9439db276d168ac9f9287deccec63acc581" + } + }, + { + "Alias": "chainlink-canton-committee-verifier-0", + "Mode": "standalone", + "Name": "chainlink-canton-committee-verifier-0", + "SignerAddressByFamily": { + "canton": "04e7db13a109f40c8d4590942d31729c4047ecd9b67c2043e391c6d9ef93001e12c0f01452e4e5f7743c45fad0be9c5b6cec1abd4006dc9b796511b8799fcb040f", + "evm": "0xedadd85f6d13c23817e935da32494f200971ab6d" + } + } + ] + }, + "PyroscopeURL": "" + }, + "UseTestRouter": false + }, + "id": "a6281650-a92b-4ab9-850b-987382c3a518", + "input": { + "chainOverrides": [ + 8706591216959472610 + ], + "payload": { + "chains": [ + { + "chainselector": 8706591216959472610, + "remotechains": { + "16015286601757825753": { + "defaultinboundccvs": [ + { + "qualifier": "default", + "type": "CommitteeVerifier", + "version": "0.1.0" + } + ], + "defaultoutboundccvs": [ + { + "qualifier": "default", + "type": "CommitteeVerifier", + "version": "0.1.0" + } + ], + "feequoterdestchainconfig": { + "linkfeemultiplierpercent": 100, + "usdperunitgas": 38 + } + } + } + } + ], + "mcms": { + "description": "Local devnet \u2014 Configure Canton \u2194 Sepolia CCIP lanes (Canton side)", + "overridePreviousRoot": true, + "qualifier": "ccipOwner", + "timelockAction": "schedule", + "timelockDelay": "1s", + "validUntil": 1780700211 + } + } + }, + "name": "configure-chains-for-lanes-from-topology" + } + ] + }, + "action": "schedule", + "delay": "1s", + "timelockAddresses": { + "8706591216959472610": "0xf2919888cc1303107e124efa6ebc00348528363bccb9ce8c19a2de9637a6215d" + }, + "operations": [ + { + "chainSelector": 8706591216959472610, + "transactions": [ + { + "contractType": "CantonGlobalConfig", + "tags": [ + "changeset:a6281650-a92b-4ab9-850b-987382c3a518" + ], + "to": "0xbcf5112b458cef1c45a8fba8f36da4b89e07b06ed5419c3ca97594d00f0a0096", + "data": "ARQxNjAxNTI4NjYwMTc1NzgyNTc1MwEAAAAAAAAAFAEAAAAAAAKrmBQ4ZXfYNQ1YFBmJdNFsPHVqY4+9YgFuZXhlY3V0b3Ita2Fia3NAcGFydGljaXBhbnQxLWxvY2FscGFydHktMTo6MTIyMGYxMWIxYmM2YTg4YjY1MDQwNjdlOGFjY2M3Yjg0MTMwM2RhYzExNDZjMGI1NDliN2I4N2EwOGZkZWQyMjY2ZjYAAXdjb21taXR0ZWV2ZXJpZmllci10ZGR3YUBwYXJ0aWNpcGFudDEtbG9jYWxwYXJ0eS0xOjoxMjIwZjExYjFiYzZhODhiNjUwNDA2N2U4YWNjYzdiODQxMzAzZGFjMTE0NmMwYjU0OWI3Yjg3YTA4ZmRlZDIyNjZmNgIxMAIyNQ==", + "additionalFields": { + "targetInstanceAddress": "globalconfig-gyszh@participant1-localparty-1::1220f11b1bc6a88b6504067e8accc7b841303dac1146c0b549b7b87a08fded2266f6", + "functionName": "ApplyDestChainConfigUpdates", + "targetCid": "", + "contractIds": null, + "targetTemplateId": "#ccip-core:CCIP.GlobalConfig:GlobalConfig" + } + }, + { + "contractType": "Executor", + "tags": [ + "changeset:a6281650-a92b-4ab9-850b-987382c3a518" + ], + "to": "0x66399c6f398943ee91159aed076732a2299168720eb415b5b2c6f758f9aeda79", + "data": "AAEUMTYwMTUyODY2MDE3NTc4MjU3NTMBMAE=", + "additionalFields": { + "targetInstanceAddress": "executor-kabks@participant1-localparty-1::1220f11b1bc6a88b6504067e8accc7b841303dac1146c0b549b7b87a08fded2266f6", + "functionName": "ApplyDestChainUpdates", + "targetCid": "", + "contractIds": null, + "targetTemplateId": "#ccip-executor:CCIP.Executor:Executor" + } + }, + { + "contractType": "FeeQuoter", + "tags": [ + "changeset:a6281650-a92b-4ab9-850b-987382c3a518" + ], + "to": "0x834942762ebee655c0e937be89edfa8a9d691f2c829c9ab3e5454ff06ae89131", + "data": "ARQxNjAxNTI4NjYwMTc1NzgyNTc1MwEAAAAAAAB9AAAAAAAAehIAAAAAAAAAAAAAAAAAAAAAFAAAAAAAAw1AAzEwMAIyNQAAAAAAAV+Q", + "additionalFields": { + "targetInstanceAddress": "feequoter-ykwcl@participant1-localparty-1::1220f11b1bc6a88b6504067e8accc7b841303dac1146c0b549b7b87a08fded2266f6", + "functionName": "ApplyFeeQuoterDestChainConfigUpdates", + "targetCid": "", + "contractIds": null, + "targetTemplateId": "#ccip-core:CCIP.FeeQuoter:FeeQuoter" + } + }, + { + "contractType": "FeeQuoter", + "tags": [ + "changeset:a6281650-a92b-4ab9-850b-987382c3a518" + ], + "to": "0x834942762ebee655c0e937be89edfa8a9d691f2c829c9ab3e5454ff06ae89131", + "data": "AV9wYXJ0aWNpcGFudDEtbG9jYWxwYXJ0eS0xOjoxMjIwZjExYjFiYzZhODhiNjUwNDA2N2U4YWNjYzdiODQxMzAzZGFjMTE0NmMwYjU0OWI3Yjg3YTA4ZmRlZDIyNjZmNgA=", + "additionalFields": { + "targetInstanceAddress": "feequoter-ykwcl@participant1-localparty-1::1220f11b1bc6a88b6504067e8accc7b841303dac1146c0b549b7b87a08fded2266f6", + "functionName": "ApplyPriceUpdatersUpdate", + "targetCid": "", + "contractIds": null, + "targetTemplateId": "#ccip-core:CCIP.FeeQuoter:FeeQuoter" + } + }, + { + "contractType": "FeeQuoter", + "tags": [ + "changeset:a6281650-a92b-4ab9-850b-987382c3a518" + ], + "to": "0x834942762ebee655c0e937be89edfa8a9d691f2c829c9ab3e5454ff06ae89131", + "data": "AAEUMTYwMTUyODY2MDE3NTc4MjU3NTMAAjM4X3BhcnRpY2lwYW50MS1sb2NhbHBhcnR5LTE6OjEyMjBmMTFiMWJjNmE4OGI2NTA0MDY3ZThhY2NjN2I4NDEzMDNkYWMxMTQ2YzBiNTQ5YjdiODdhMDhmZGVkMjI2NmY2", + "additionalFields": { + "targetInstanceAddress": "feequoter-ykwcl@participant1-localparty-1::1220f11b1bc6a88b6504067e8accc7b841303dac1146c0b549b7b87a08fded2266f6", + "functionName": "UpdatePrices", + "targetCid": "", + "contractIds": null, + "targetTemplateId": "#ccip-core:CCIP.FeeQuoter:FeeQuoter" + } + }, + { + "contractType": "CantonGlobalConfig", + "tags": [ + "changeset:a6281650-a92b-4ab9-850b-987382c3a518" + ], + "to": "0xbcf5112b458cef1c45a8fba8f36da4b89e07b06ed5419c3ca97594d00f0a0096", + "data": "ARQxNjAxNTI4NjYwMTc1NzgyNTc1MwEBIAAAAAAAAAAAAAAAAKlORXRFU/SyvqnfuJeaApYrmAcyAXdjb21taXR0ZWV2ZXJpZmllci10ZGR3YUBwYXJ0aWNpcGFudDEtbG9jYWxwYXJ0eS0xOjoxMjIwZjExYjFiYzZhODhiNjUwNDA2N2U4YWNjYzdiODQxMzAzZGFjMTE0NmMwYjU0OWI3Yjg3YTA4ZmRlZDIyNjZmNgA=", + "additionalFields": { + "targetInstanceAddress": "globalconfig-gyszh@participant1-localparty-1::1220f11b1bc6a88b6504067e8accc7b841303dac1146c0b549b7b87a08fded2266f6", + "functionName": "ApplySourceChainConfigUpdates", + "targetCid": "", + "contractIds": null, + "targetTemplateId": "#ccip-core:CCIP.GlobalConfig:GlobalConfig" + } + } + ] + } + ] +} \ No newline at end of file diff --git a/experimental/analyzer/upf/upf.go b/experimental/analyzer/upf/upf.go index d94724b8b..0e8710c89 100644 --- a/experimental/analyzer/upf/upf.go +++ b/experimental/analyzer/upf/upf.go @@ -13,6 +13,7 @@ import ( chainsel "github.com/smartcontractkit/chain-selectors" "github.com/smartcontractkit/mcms" mcmsaptossdk "github.com/smartcontractkit/mcms/sdk/aptos" + mcmscantonsdk "github.com/smartcontractkit/mcms/sdk/canton" mcmssuisdk "github.com/smartcontractkit/mcms/sdk/sui" mcmstonsdk "github.com/smartcontractkit/mcms/sdk/ton" mcmstypes "github.com/smartcontractkit/mcms/types" @@ -275,6 +276,9 @@ func batchOperationsToUpfDecodedCalls(ctx context.Context, proposalContext mcmsa case chainsel.FamilyTon: describedTxs, err = mcmsanalyzer.AnalyzeTONTransactions(proposalContext, chainSel, batch.Transactions) + case chainsel.FamilyCanton: + describedTxs, err = mcmsanalyzer.AnalyzeCantonTransactions(proposalContext, chainSel, batch.Transactions) + default: for callIdx, mcmsTx := range batch.Transactions { decodedCalls[batchIdx][callIdx] = &DecodedInnerCall{ @@ -363,6 +367,15 @@ func analyzeTransaction( return nil, "", err } + return analyzeResult, "", nil + + case chainsel.FamilyCanton: + decoder := mcmscantonsdk.NewDecoder() + analyzeResult, err := mcmsanalyzer.AnalyzeCantonTransaction(proposalCtx, decoder, uint64(mcmsOp.ChainSelector), mcmsOp.Transaction) + if err != nil { + return nil, "", err + } + return analyzeResult, "", nil default: return nil, "", fmt.Errorf("unsupported chain family: %s", chainFamily) diff --git a/experimental/analyzer/upf/upf_canton_test.go b/experimental/analyzer/upf/upf_canton_test.go new file mode 100644 index 000000000..20aabfac3 --- /dev/null +++ b/experimental/analyzer/upf/upf_canton_test.go @@ -0,0 +1,59 @@ +package upf + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + chainsel "github.com/smartcontractkit/chain-selectors" + factory "github.com/smartcontractkit/chainlink-canton/bindings/generated/latest/ccip/factory" + mcmscantonsdk "github.com/smartcontractkit/mcms/sdk/canton" + mcmstypes "github.com/smartcontractkit/mcms/types" + + "github.com/smartcontractkit/chainlink-deployments-framework/deployment" + mcmsanalyzer "github.com/smartcontractkit/chainlink-deployments-framework/experimental/analyzer" +) + +// TestBatchOperationsToUpfDecodedCalls_Canton proves the UPF batch-decode path produces a decoded +// Canton call (previously the default branch emitted "canton transaction decoding is not supported"). +func TestBatchOperationsToUpfDecodedCalls_Canton(t *testing.T) { + t.Parallel() + + deployParams := factory.DeployRMNRemoteParams{ + InstanceId: "rmn-remote-1", + RmnOwner: "alice::abc123", + CcipOwner: "bob::def456", + } + rawHex, err := deployParams.MarshalHex() + require.NoError(t, err) + + af, err := json.Marshal(mcmscantonsdk.AdditionalFields{ + TargetInstanceAddress: "ccip-factory-1@alice::abc123", + FunctionName: "DeployRMNRemote", + TargetTemplateID: "#pkg:CCIP.Factory:CCIPFactory", + }) + require.NoError(t, err) + + batches := []mcmstypes.BatchOperation{ + { + ChainSelector: mcmstypes.ChainSelector(chainsel.CANTON_TESTNET.Selector), + Transactions: []mcmstypes.Transaction{ + { + To: "0xfeed", + Data: []byte(rawHex), // go-daml MarshalHex returns the raw operation bytes + AdditionalFields: af, + }, + }, + }, + } + + proposalCtx := &mcmsanalyzer.DefaultProposalContext{AddressesByChain: deployment.AddressesByChain{}} + + decoded, err := batchOperationsToUpfDecodedCalls(t.Context(), proposalCtx, deployment.Environment{}, batches) + require.NoError(t, err) + require.Len(t, decoded, 1) + require.Len(t, decoded[0], 1) + require.NotNil(t, decoded[0][0].Data) + require.Equal(t, "CCIPFactory::DeployRMNRemote", decoded[0][0].Data.FunctionName) +} diff --git a/experimental/analyzer/upf/upf_test.go b/experimental/analyzer/upf/upf_test.go index 19ff1f0c3..b01ae2744 100644 --- a/experimental/analyzer/upf/upf_test.go +++ b/experimental/analyzer/upf/upf_test.go @@ -3,6 +3,7 @@ package upf import ( "context" "fmt" + "os" "strings" "testing" @@ -10,6 +11,7 @@ import ( chainsel "github.com/smartcontractkit/chain-selectors" "github.com/smartcontractkit/mcms" mcmssdk "github.com/smartcontractkit/mcms/sdk" + mcmscantonsdk "github.com/smartcontractkit/mcms/sdk/canton" mcmsevmsdk "github.com/smartcontractkit/mcms/sdk/evm" mcmssolanasdk "github.com/smartcontractkit/mcms/sdk/solana" mcmssuisdk "github.com/smartcontractkit/mcms/sdk/sui" @@ -195,6 +197,8 @@ func convertTimelockProposal(ctx context.Context, t *testing.T, timelockProposal converters[chain] = converter case chainsel.FamilyTon: converters[chain] = mcmstonsdk.NewTimelockConverter(mcmstonsdk.DefaultSendAmount) + case chainsel.FamilyCanton: + converters[chain] = mcmscantonsdk.NewTimelockConverter() default: t.Fatalf("unsupported chain family %s", chainFamily) } @@ -206,6 +210,67 @@ func convertTimelockProposal(ctx context.Context, t *testing.T, timelockProposal return &mcmProposal } +// TestUpfConvertTimelockProposalWithCanton exercises the full UPF conversion pipeline on a Canton proposal, +// producing an actual YAML string (the `.upf.yaml` that reviewers see). +func TestUpfConvertTimelockProposalWithCanton(t *testing.T) { + t.Parallel() + + proposalJSON, err := os.ReadFile("../testdata/canton_test_proposal.json") + require.NoError(t, err) + + env := deployment.Environment{ + DataStore: datastore.NewMemoryDataStore().Seal(), + ExistingAddresses: deployment.NewMemoryAddressBook(), + } + proposalCtx, err := mcmsanalyzer.NewDefaultProposalContext(env) + require.NoError(t, err) + + tests := []struct { + name string + signers map[mcmstypes.ChainSelector][]common.Address + assertion func(*testing.T, string, error) + }{ + { + name: "Canton configure-chains proposal decodes to YAML", + signers: map[mcmstypes.ChainSelector][]common.Address{}, + assertion: func(t *testing.T, gotUpf string, err error) { + t.Helper() + require.NoError(t, err) + require.NotEmpty(t, gotUpf) + + // YAML document separator must be present. + require.Contains(t, gotUpf, "---") + + // Canton chain family must appear. + require.Contains(t, gotUpf, "canton") + + // Decoded function names from the real proposal must be present, + // proving Canton ops were decoded (not "decoding is not supported"). + require.Contains(t, gotUpf, "ApplyDestChainConfigUpdates") + require.Contains(t, gotUpf, "ApplyFeeQuoterDestChainConfigUpdates") + require.Contains(t, gotUpf, "ApplyDestChainUpdates") + + // Decoded operationData must appear in the calls — confirms inputs were rendered. + require.Contains(t, gotUpf, "operationData") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + timelockProposal, err := mcms.NewTimelockProposal(strings.NewReader(string(proposalJSON))) + require.NoError(t, err) + mcmProposal := convertTimelockProposal(t.Context(), t, timelockProposal) + + got, err := UpfConvertTimelockProposal(t.Context(), proposalCtx, env, timelockProposal, mcmProposal, tt.signers) + + tt.assertion(t, got, err) + }) + } +} + // ----- data ----- var timelockProposalRMNCurse = `{ "version": "v1", diff --git a/experimental/analyzer/utils.go b/experimental/analyzer/utils.go index f0c6007f2..a51e6e5a9 100644 --- a/experimental/analyzer/utils.go +++ b/experimental/analyzer/utils.go @@ -31,7 +31,8 @@ func getFieldValue(argument any) FieldValue { var value FieldValue switch arg := argument.(type) { - // Pretty-print byte arrays and addresses + case nil: + value = SimpleField{Value: "null"} case []byte: value = BytesField{Value: arg} case aptos.AccountAddress: @@ -43,7 +44,6 @@ func getFieldValue(argument any) FieldValue { default: //nolint:exhaustive // default case covers everything else switch reflect.TypeOf(arg).Kind() { - // If the field is a slice or array, iterate over every element individually case reflect.Array, reflect.Slice: array := ArrayField{} v := reflect.ValueOf(arg) @@ -52,7 +52,6 @@ func getFieldValue(argument any) FieldValue { } value = array default: - // Simply print the field as-is value = SimpleField{Value: fmt.Sprintf("%v", arg)} } } diff --git a/experimental/analyzer/utils_test.go b/experimental/analyzer/utils_test.go new file mode 100644 index 000000000..37cddd5cc --- /dev/null +++ b/experimental/analyzer/utils_test.go @@ -0,0 +1,27 @@ +package analyzer + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestGetFieldValue_Nil(t *testing.T) { + t.Parallel() + + field := getFieldValue(nil) + assert.Equal(t, SimpleField{Value: "null"}, field) +} + +// TestCantonFieldValue_MapWithNilValue verifies that cantonFieldValue (Canton-scoped) converts +// map[string]any to StructField, which getFieldValue does not do (to avoid affecting other chains). +func TestCantonFieldValue_MapWithNilValue(t *testing.T) { + t.Parallel() + + field := cantonFieldValue(map[string]any{"optional": nil}) + structField, ok := field.(StructField) + assert.True(t, ok) + assert.Len(t, structField.Fields, 1) + assert.Equal(t, "optional", structField.Fields[0].Name) + assert.Equal(t, SimpleField{Value: "null"}, structField.Fields[0].Value) +} diff --git a/go.mod b/go.mod index f4f135873..e2625ea0e 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/smartcontractkit/ccip-owner-contracts v0.1.0 github.com/smartcontractkit/chain-selectors v1.0.101 github.com/smartcontractkit/chainlink-aptos v0.0.0-20260428085939-5c70de12dbfc + github.com/smartcontractkit/chainlink-canton v0.0.0-20260602133237-99f834640c9d github.com/smartcontractkit/chainlink-ccip/chains/evm v0.0.0-20260512180815-d7a89b0a5784 github.com/smartcontractkit/chainlink-ccip/chains/solana v0.0.0-20260129103204-4c8453dd8139 github.com/smartcontractkit/chainlink-ccip/chains/solana/gobindings v0.0.0-20260129103204-4c8453dd8139 @@ -43,8 +44,9 @@ require ( github.com/smartcontractkit/chainlink-ton v1.0.5-0.20260514223130-48bc90aca745 github.com/smartcontractkit/chainlink-tron/relayer v0.0.11-0.20251014143056-a0c6328c91e9 github.com/smartcontractkit/freeport v0.1.3-0.20250828155247-add56fa28aad + github.com/smartcontractkit/go-daml v0.0.0-20260604143752-c6f6567940ba github.com/smartcontractkit/libocr v0.0.0-20260304194147-a03701e2c02e - github.com/smartcontractkit/mcms v0.46.0 + github.com/smartcontractkit/mcms v0.47.0 github.com/spf13/cobra v1.10.2 github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 @@ -98,13 +100,11 @@ require ( github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/onsi/ginkgo/v2 v2.22.1 // indirect github.com/onsi/gomega v1.36.2 // indirect - github.com/smartcontractkit/chainlink-canton v0.0.0-20260602133237-99f834640c9d // indirect github.com/smartcontractkit/chainlink-common v0.11.2-0.20260506120607-7f10be016c89 // indirect github.com/smartcontractkit/chainlink-common/pkg/chipingress v0.0.10 // indirect github.com/smartcontractkit/chainlink-protos/cre/go v0.0.0-20260505131349-78e491b80735 // indirect github.com/smartcontractkit/chainlink-protos/linking-service/go v0.0.0-20251002192024-d2ad9222409b // indirect github.com/smartcontractkit/chainlink-protos/node-platform v0.0.0-20260211172625-dff40e83b3c9 // indirect - github.com/smartcontractkit/go-daml v0.0.0-20260604143752-c6f6567940ba // indirect github.com/stellar/go-xdr v0.0.0-20260312225820-cc2b0611aabf // indirect go.uber.org/goleak v1.3.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index 61a412682..a798582c2 100644 --- a/go.sum +++ b/go.sum @@ -713,8 +713,8 @@ github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7 h1:12i github.com/smartcontractkit/grpc-proxy v0.0.0-20240830132753-a7e17fec5ab7/go.mod h1:FX7/bVdoep147QQhsOPkYsPEXhGZjeYx6lBSaSXtZOA= github.com/smartcontractkit/libocr v0.0.0-20260304194147-a03701e2c02e h1:poXTj5cFVM6XfC4HICIDYkDVc/A6OYB0eeID0wU2JQE= github.com/smartcontractkit/libocr v0.0.0-20260304194147-a03701e2c02e/go.mod h1:PLdNK6GlqfxIWXzziPkU7dCAVlVFeYkyyW7AQY0R+4Q= -github.com/smartcontractkit/mcms v0.46.0 h1:IxXVZ6km/orQGNsXs/qs42s65CYnjxNMrnntWLUF9KA= -github.com/smartcontractkit/mcms v0.46.0/go.mod h1:PBWZPScZKC87jDMxcd5WvKDdlvTgA7k8qHkCeNkGBN8= +github.com/smartcontractkit/mcms v0.47.0 h1:MiwpQZwZUwdRJ1HGEO3EkLHj5+g95P84HXcHeY9gMFw= +github.com/smartcontractkit/mcms v0.47.0/go.mod h1:PBWZPScZKC87jDMxcd5WvKDdlvTgA7k8qHkCeNkGBN8= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=