Skip to content

Commit 5cbf05b

Browse files
committed
feat: Canton MCMS proposal analyzer
1 parent 52ef992 commit 5cbf05b

14 files changed

Lines changed: 1544 additions & 14 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"chainlink-deployments-framework": minor
3+
---
4+
5+
feat(analyzer): implement Canton MCMS proposal analyzer
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package analyzer
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"reflect"
7+
"sort"
8+
9+
mcmssdk "github.com/smartcontractkit/mcms/sdk"
10+
mcmscantonsdk "github.com/smartcontractkit/mcms/sdk/canton"
11+
"github.com/smartcontractkit/mcms/types"
12+
)
13+
14+
func AnalyzeCantonTransactions(ctx ProposalContext, chainSelector uint64, txs []types.Transaction) ([]*DecodedCall, error) {
15+
decoder := mcmscantonsdk.NewDecoder()
16+
decodedTxs := make([]*DecodedCall, len(txs))
17+
for i, op := range txs {
18+
analyzedTransaction, err := AnalyzeCantonTransaction(ctx, decoder, chainSelector, op)
19+
if err != nil {
20+
return nil, fmt.Errorf("failed to analyze Canton transaction %d: %w", i, err)
21+
}
22+
decodedTxs[i] = analyzedTransaction
23+
}
24+
25+
return decodedTxs, nil
26+
}
27+
28+
// AnalyzeCantonTransaction decodes a single Canton MCMS transaction. Each transaction already
29+
// describes the target call (Daml choice or factory deploy) via its AdditionalFields, so it is decoded directly
30+
func AnalyzeCantonTransaction(ctx ProposalContext, decoder *mcmscantonsdk.Decoder, chainSelector uint64, mcmsTx types.Transaction) (*DecodedCall, error) {
31+
contractType, contractVersion := resolveContractInfo(ctx, chainSelector, mcmsTx)
32+
33+
var additionalFields mcmscantonsdk.AdditionalFields
34+
if err := json.Unmarshal(mcmsTx.AdditionalFields, &additionalFields); err != nil {
35+
return nil, fmt.Errorf("failed to unmarshal Canton additional fields: %w", err)
36+
}
37+
38+
// Pass the resolved contract type as the fallback contract-type key
39+
// The decoder prefers the entity name parsed from AdditionalFields.TargetTemplateID when present.
40+
decodedOp, err := decoder.Decode(mcmsTx, contractType)
41+
if err != nil {
42+
errStr := fmt.Errorf("failed to decode Canton transaction %q: %w", additionalFields.FunctionName, err)
43+
44+
return &DecodedCall{
45+
Address: mcmsTx.To,
46+
Method: errStr.Error(),
47+
ContractType: contractType,
48+
ContractVersion: contractVersion,
49+
}, nil
50+
}
51+
52+
namedArgs, err := cantonToNamedFields(decodedOp)
53+
if err != nil {
54+
return nil, fmt.Errorf("failed to convert decoded operation to named arguments: %w", err)
55+
}
56+
57+
return &DecodedCall{
58+
Address: mcmsTx.To,
59+
Method: decodedOp.MethodName(),
60+
Inputs: namedArgs,
61+
Outputs: []NamedField{},
62+
ContractType: contractType,
63+
ContractVersion: contractVersion,
64+
}, nil
65+
}
66+
67+
// cantonToNamedFields is like toNamedFields but uses cantonFieldValue so that nested Daml records
68+
// (returned as map[string]any by the Canton decoder's toDisplayArg) become StructField.
69+
// Kept here rather than in utils.go to avoid leaking Canton-specific logic into the shared utility.
70+
func cantonToNamedFields(decodedOp mcmssdk.DecodedOperation) ([]NamedField, error) {
71+
args := decodedOp.Args()
72+
keys := decodedOp.Keys()
73+
if len(keys) != len(args) {
74+
return nil, fmt.Errorf("mismatched keys and arguments length: %d keys, %d arguments", len(keys), len(args))
75+
}
76+
namedArgs := make([]NamedField, len(args))
77+
for i := range args {
78+
namedArgs[i] = NamedField{
79+
Name: keys[i],
80+
Value: cantonFieldValue(args[i]),
81+
RawValue: args[i],
82+
}
83+
}
84+
85+
return namedArgs, nil
86+
}
87+
88+
// cantonFieldValue is like getFieldValue but also converts map[string]any to StructField.
89+
// Canton decoded args use map[string]any for nested Daml records (via toDisplayArg in the Canton
90+
// decoder). This is Canton-scoped: other chains (e.g. TON) also return map[string]any in some
91+
// decoded fields but rely on the default fmt.Sprintf("%v", ...) rendering via getFieldValue.
92+
func cantonFieldValue(argument any) FieldValue {
93+
if m, ok := argument.(map[string]any); ok {
94+
return mapToStructField(m)
95+
}
96+
// For slices, recurse so nested maps within arrays are also converted.
97+
if kind := reflect.TypeOf(argument); kind != nil {
98+
if kind.Kind() == reflect.Array || kind.Kind() == reflect.Slice {
99+
array := ArrayField{}
100+
v := reflect.ValueOf(argument)
101+
for i := range v.Len() {
102+
array.Elements = append(array.Elements, cantonFieldValue(v.Index(i).Interface()))
103+
}
104+
105+
return array
106+
}
107+
}
108+
109+
return getFieldValue(argument)
110+
}
111+
112+
// mapToStructField converts a map[string]any to a StructField with sorted keys.
113+
func mapToStructField(m map[string]any) StructField {
114+
keys := make([]string, 0, len(m))
115+
for k := range m {
116+
keys = append(keys, k)
117+
}
118+
sort.Strings(keys)
119+
120+
fields := make([]NamedField, 0, len(m))
121+
for _, k := range keys {
122+
fields = append(fields, NamedField{
123+
Name: k,
124+
Value: cantonFieldValue(m[k]),
125+
})
126+
}
127+
128+
return StructField{Fields: fields}
129+
}

0 commit comments

Comments
 (0)