Skip to content

Commit 15d3401

Browse files
feat(operations-gen): add deploy_contract_types for same-ABI multi-label deploy (#1056)
Currently operations-gen does not support generating operation with the same contract byte code but each with different contract types (MCMS setup - proposer,canceller,bypasser). Adds a new `deploy_contract_types` field to the EVM contract config that allows a single contract entry to register multiple ContractType labels (e.g. ProposerManyChainMultiSig, BypasserManyChainMultiSig, CancellerManyChainMultiSig) in the generated BytecodeByTypeAndVersion map. ```yaml version: "1.0.0" chain_family: evm input: gobindings_package: "github.com/smartcontractkit/chainlink-deployments-framework/tools/operations-gen/testdata/evm/gobindings" zksync_bindings_package: "github.com/smartcontractkit/chainlink-deployments-framework/tools/operations-gen/testdata/evm/zksync_bindings" output: base_path: "." contracts: - contract_name: ManyChainMultiSig version: "1.0.0" package_name: many_chain_multi_sig zksync_bytecode: ManyChainMultiSigZkBytecode deploy_contract_types: - ProposerManyChainMultiSig - BypasserManyChainMultiSig - CancellerManyChainMultiSig functions: - name: owner access: public ``` will generate ```go ... var ProposerManyChainMultiSigContractType cldf_deployment.ContractType = "ProposerManyChainMultiSig" var ProposerManyChainMultiSigTypeAndVersion = cldf_deployment.NewTypeAndVersion(ProposerManyChainMultiSigContractType, *Version) var BypasserManyChainMultiSigContractType cldf_deployment.ContractType = "BypasserManyChainMultiSig" var BypasserManyChainMultiSigTypeAndVersion = cldf_deployment.NewTypeAndVersion(BypasserManyChainMultiSigContractType, *Version) var CancellerManyChainMultiSigContractType cldf_deployment.ContractType = "CancellerManyChainMultiSig" var CancellerManyChainMultiSigTypeAndVersion = cldf_deployment.NewTypeAndVersion(CancellerManyChainMultiSigContractType, *Version) var Deploy = contract.NewDeploy(contract.DeployParams[ConstructorArgs]{ Name: "many-chain-multi-sig:deploy", Version: Version, Description: "Deploys the ManyChainMultiSig contract", ContractMetadata: gobindings.ManyChainMultiSigMetaData, BytecodeByTypeAndVersion: map[string]contract.Bytecode{ ProposerManyChainMultiSigTypeAndVersion.String(): { EVM: common.FromHex(gobindings.ManyChainMultiSigMetaData.Bin), ZkSyncVM: zkbindings.ManyChainMultiSigZkBytecode, }, BypasserManyChainMultiSigTypeAndVersion.String(): { EVM: common.FromHex(gobindings.ManyChainMultiSigMetaData.Bin), ZkSyncVM: zkbindings.ManyChainMultiSigZkBytecode, }, CancellerManyChainMultiSigTypeAndVersion.String(): { EVM: common.FromHex(gobindings.ManyChainMultiSigMetaData.Bin), ZkSyncVM: zkbindings.ManyChainMultiSigZkBytecode, }, }, }) ... ```
1 parent fc56aeb commit 15d3401

9 files changed

Lines changed: 351 additions & 37 deletions

File tree

.changeset/long-times-divide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"operations-gen": minor
3+
---
4+
5+
feat: add deploy_contract_types for same-ABI multi-label deploy

tools/operations-gen/README.md

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,17 @@ contracts:
127127
access: owner # Write op with MCMS support
128128
- name: getTokenPrice
129129
access: public # Read op (or public write op)
130+
131+
# Same ABI, multiple datastore labels — one Deploy handles all three.
132+
- contract_name: ManyChainMultiSig
133+
version: "1.0.0"
134+
deploy_contract_types:
135+
- ProposerManyChainMultiSig
136+
- BypasserManyChainMultiSig
137+
- CancellerManyChainMultiSig
138+
functions:
139+
- name: setConfig
140+
access: owner
130141
```
131142
132143
### Top-level fields
@@ -148,8 +159,9 @@ contracts:
148159
| `gobindings_package` | No | Optional full Go import path or relative filesystem path override for this contract's abigen-generated bindings package. Required only when `input.gobindings_package` is not set. |
149160
| `package_name` | No | Override the generated Go package name. Defaults to `snake_case(contract_name)`. |
150161
| `version_path` | No | Override the directory path derived from the version. Defaults to `v{major}_{minor}_{patch}`. |
151-
| `omit_deploy` | No | Skip generation of the `Deploy` operation and bytecode constant. Defaults to `false`. Cannot be combined with `zksync_bytecode`. |
152-
| `zksync_bytecode` | No | zkSync VM deploy bytecode symbol, or `{package, symbol}`. Package defaults to `input.zksync_bindings_package`, then the contract's `gobindings_package`. |
162+
| `omit_deploy` | No | Skip generation of the `Deploy` operation and bytecode constant. Defaults to `false`. Cannot be combined with `zksync_bytecode` or `deploy_contract_types`. |
163+
| `deploy_contract_types` | No | List of `ContractType` labels (e.g. `ProposerManyChainMultiSig`) that share this contract's ABI and bytecode but need distinct datastore entries. Labels must be valid Go exported identifiers. When set, **only** these labels appear as keys in `BytecodeByTypeAndVersion` — the base `contract_name` type is excluded. Each label gets exported `var <Label>ContractType` and `var <Label>TypeAndVersion` vars. An empty list is rejected. Cannot be combined with `omit_deploy`. See [Deploy contract types](#deploy-contract-types). |
164+
| `zksync_bytecode` | No | zkSync VM deploy bytecode symbol, or `{package, symbol}`. Package defaults to `input.zksync_bindings_package`, then the contract's `gobindings_package`. |
153165

154166
### Function access control
155167

@@ -163,6 +175,61 @@ For `access: role`, `DEFAULT_ADMIN_ROLE` maps to the all-zero role and any other
163175
human-readable role name is hashed as `keccak256("<ROLE_NAME>")`. Raw bytes32
164176
role hashes are rejected so configs remain readable.
165177

178+
## Deploy contract types
179+
180+
Some contracts are deployed multiple times with different semantic roles, each requiring a distinct
181+
`ContractType` label in the datastore (e.g. `ProposerManyChainMultiSig`, `BypasserManyChainMultiSig`,
182+
`CancellerManyChainMultiSig`). Because all three share the same ABI and bytecode, using three
183+
separate YAML contract entries would generate three near-identical files. `deploy_contract_types`
184+
solves this: one entry, one generated package, one `Deploy` var — but the `BytecodeByTypeAndVersion`
185+
map holds a key for every label so the caller can deploy under whichever type it needs.
186+
187+
```yaml
188+
- contract_name: ManyChainMultiSig
189+
version: "1.0.0"
190+
deploy_contract_types:
191+
- ProposerManyChainMultiSig
192+
- BypasserManyChainMultiSig
193+
- CancellerManyChainMultiSig
194+
functions:
195+
- name: setConfig
196+
access: owner
197+
```
198+
199+
This generates:
200+
201+
```go
202+
var ContractType cldf_deployment.ContractType = "ManyChainMultiSig"
203+
var ProposerManyChainMultiSigContractType cldf_deployment.ContractType = "ProposerManyChainMultiSig"
204+
var ProposerManyChainMultiSigTypeAndVersion = cldf_deployment.NewTypeAndVersion(ProposerManyChainMultiSigContractType, *Version)
205+
var BypasserManyChainMultiSigContractType cldf_deployment.ContractType = "BypasserManyChainMultiSig"
206+
var BypasserManyChainMultiSigTypeAndVersion = cldf_deployment.NewTypeAndVersion(BypasserManyChainMultiSigContractType, *Version)
207+
var CancellerManyChainMultiSigContractType cldf_deployment.ContractType = "CancellerManyChainMultiSig"
208+
var CancellerManyChainMultiSigTypeAndVersion = cldf_deployment.NewTypeAndVersion(CancellerManyChainMultiSigContractType, *Version)
209+
210+
var Deploy = contract.NewDeploy(contract.DeployParams[ConstructorArgs]{
211+
BytecodeByTypeAndVersion: map[string]contract.Bytecode{
212+
ProposerManyChainMultiSigTypeAndVersion.String(): { /* ... */ },
213+
BypasserManyChainMultiSigTypeAndVersion.String(): { /* ... */ },
214+
CancellerManyChainMultiSigTypeAndVersion.String(): { /* ... */ },
215+
},
216+
})
217+
```
218+
219+
The caller selects the role at deploy time by passing the appropriate `TypeAndVersion` to `Deploy`:
220+
221+
```go
222+
many_chain_multi_sig.Deploy.Execute(b, chain, contract.DeployInput[many_chain_multi_sig.ConstructorArgs]{
223+
TypeAndVersion: many_chain_multi_sig.ProposerManyChainMultiSigTypeAndVersion,
224+
})
225+
```
226+
227+
**Rules:**
228+
- Labels must be valid Go exported identifiers, non-empty, unique, and different from `contract_name`.
229+
- The list must contain at least one entry; an empty list is rejected.
230+
- Cannot be combined with `omit_deploy: true`.
231+
- The base `contract_name` type is **not** included in `BytecodeByTypeAndVersion` when this field is set, and `TypeAndVersion` is not emitted (use the per-label `*TypeAndVersion` vars instead).
232+
166233
## Gobindings requirements
167234

168235
The generator expects an abigen-generated package that exports the standard metadata symbol:

tools/operations-gen/generate/templates/evm/operations.tmpl

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,13 @@ import (
3030

3131
var ContractType cldf_deployment.ContractType = "{{.ContractType}}"
3232
var Version = semver.MustParse("{{.Version}}")
33+
{{- if not .DeployContractTypes}}
3334
var TypeAndVersion = cldf_deployment.NewTypeAndVersion(ContractType, *Version)
35+
{{- end}}
36+
{{- range .DeployContractTypes}}
37+
var {{.}}ContractType cldf_deployment.ContractType = "{{.}}"
38+
var {{.}}TypeAndVersion = cldf_deployment.NewTypeAndVersion({{.}}ContractType, *Version)
39+
{{- end}}
3440
{{range .StructDefs}}
3541
type {{.Name}} struct {
3642
{{- range .Fields}}
@@ -62,12 +68,23 @@ var Deploy = contract.NewDeploy(contract.DeployParams[ConstructorArgs]{
6268
Description: "Deploys the {{.ContractType}} contract",
6369
ContractMetadata: gobindings.{{.ContractType}}MetaData,
6470
BytecodeByTypeAndVersion: map[string]contract.Bytecode{
71+
{{- if not .DeployContractTypes}}
6572
cldf_deployment.NewTypeAndVersion(ContractType, *Version).String(): {
6673
EVM: common.FromHex(gobindings.{{.ContractType}}MetaData.Bin),
6774
{{- if .ZkSyncBytecodeSymbol}}
6875
ZkSyncVM: {{if .ZkSyncBytecodeUseGobindings}}gobindings{{else}}zkbindings{{end}}.{{.ZkSyncBytecodeSymbol}},
6976
{{- end}}
7077
},
78+
{{- else}}
79+
{{- range .DeployContractTypes}}
80+
{{.}}TypeAndVersion.String(): {
81+
EVM: common.FromHex(gobindings.{{$.ContractType}}MetaData.Bin),
82+
{{- if $.ZkSyncBytecodeSymbol}}
83+
ZkSyncVM: {{if $.ZkSyncBytecodeUseGobindings}}gobindings{{else}}zkbindings{{end}}.{{$.ZkSyncBytecodeSymbol}},
84+
{{- end}}
85+
},
86+
{{- end}}
87+
{{- end}}
7188
},
7289
})
7390
{{- end}}

tools/operations-gen/internal/families/evm/codegen.go

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,14 @@ type templateData struct {
2424
NeedsBigInt bool
2525
HasWriteOps bool
2626
OmitDeploy bool
27-
Constructor *constructorData
28-
StructDefs []structDefData
29-
ArgStructs []argStructData
30-
Operations []OperationData
31-
ContractMethods []contractMethodData
27+
// DeployContractTypes holds additional ContractType labels that share this
28+
// contract's ABI and bytecode.
29+
DeployContractTypes []string
30+
Constructor *constructorData
31+
StructDefs []structDefData
32+
ArgStructs []argStructData
33+
Operations []OperationData
34+
ContractMethods []contractMethodData
3235
}
3336

3437
type constructorData struct {
@@ -87,13 +90,14 @@ func generateOperationsFile(info *ContractInfo, tmpl *template.Template) error {
8790

8891
func prepareTemplateData(info *ContractInfo) templateData {
8992
data := templateData{
90-
PackageName: info.PackageName,
91-
PackageNameHyphen: toKebabCase(info.PackageName),
92-
ContractType: info.Name,
93-
Version: info.Version,
94-
GobindingsImport: info.GobindingsPackage,
95-
NeedsBigInt: ChecksNeedsBigInt(info),
96-
OmitDeploy: info.OmitDeploy,
93+
PackageName: info.PackageName,
94+
PackageNameHyphen: toKebabCase(info.PackageName),
95+
ContractType: info.Name,
96+
Version: info.Version,
97+
GobindingsImport: info.GobindingsPackage,
98+
NeedsBigInt: ChecksNeedsBigInt(info),
99+
OmitDeploy: info.OmitDeploy,
100+
DeployContractTypes: info.DeployContractTypes,
97101
}
98102
if info.ZkSync != nil {
99103
data.ZkSyncBytecodeSymbol = info.ZkSync.BytecodeSymbol

tools/operations-gen/internal/families/evm/config.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,21 @@ package evm
22

33
// EvmContractConfig is the EVM-specific contract configuration decoded from YAML.
44
type EvmContractConfig struct {
5-
Name string `yaml:"contract_name"`
6-
Version string `yaml:"version"`
7-
VersionPath string `yaml:"version_path,omitempty"` // Optional: override folder path derived from version
8-
PackageName string `yaml:"package_name,omitempty"` // Optional: override package name
9-
OmitDeploy bool `yaml:"omit_deploy,omitempty"` // Optional: skip Deploy operation
10-
GobindingsPackage string `yaml:"gobindings_package"` // Optional: override the derived gobindings import path or relative filesystem path for this contract.
11-
ZkSyncBytecode ZkSyncBytecodeRef `yaml:"zksync_bytecode,omitempty"`
12-
Functions []EvmFunctionConfig `yaml:"functions"`
13-
ConfigDir string `yaml:"-"`
5+
Name string `yaml:"contract_name"`
6+
Version string `yaml:"version"`
7+
VersionPath string `yaml:"version_path,omitempty"` // Optional: override folder path derived from version
8+
PackageName string `yaml:"package_name,omitempty"` // Optional: override package name
9+
OmitDeploy bool `yaml:"omit_deploy,omitempty"` // Optional: skip Deploy operation
10+
GobindingsPackage string `yaml:"gobindings_package"` // Optional: override the derived gobindings import path or relative filesystem path for this contract.
11+
ZkSyncBytecode ZkSyncBytecodeRef `yaml:"zksync_bytecode,omitempty"`
12+
// DeployContractTypes lists ContractType labels (e.g. "ProposerManyChainMultiSig") that share
13+
// the same ABI and bytecode as this contract but need separate datastore entries.
14+
// Labels must be valid Go exported identifiers. When non-nil, ONLY these labels appear as
15+
// BytecodeByTypeAndVersion keys and each gets <Label>ContractType + <Label>TypeAndVersion vars.
16+
// An empty list is rejected. Cannot be set when omit_deploy is true.
17+
DeployContractTypes []string `yaml:"deploy_contract_types,omitempty"`
18+
Functions []EvmFunctionConfig `yaml:"functions"`
19+
ConfigDir string `yaml:"-"`
1420
}
1521

1622
type EvmInputConfig struct {

tools/operations-gen/internal/families/evm/contract.go

Lines changed: 68 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"os"
77
"path/filepath"
88
"strings"
9+
"unicode"
10+
"unicode/utf8"
911

1012
"github.com/ethereum/go-ethereum/accounts/abi"
1113
"golang.org/x/mod/modfile"
@@ -32,10 +34,15 @@ type ContractInfo struct {
3234
ZkSync *ZkSyncContractInfo
3335
OutputPath string
3436
OmitDeploy bool
35-
Constructor *FunctionInfo
36-
Functions map[string]*FunctionInfo
37-
FunctionOrder []string
38-
StructDefs map[string]*structDef
37+
// DeployContractTypes holds ContractType labels that share this contract's ABI
38+
// and bytecode. When non-empty, ONLY these labels appear as BytecodeByTypeAndVersion
39+
// keys — the base ContractType is excluded. Each entry also gets an exported var.
40+
// Always empty when OmitDeploy is true.
41+
DeployContractTypes []string
42+
Constructor *FunctionInfo
43+
Functions map[string]*FunctionInfo
44+
FunctionOrder []string
45+
StructDefs map[string]*structDef
3946
}
4047

4148
// ZkSyncContractInfo holds resolved zkSync VM deploy bytecode for code generation.
@@ -108,6 +115,17 @@ func extractContractInfo(cfg EvmContractConfig, input EvmInputConfig, output Evm
108115
if cfg.OmitDeploy && !cfg.ZkSyncBytecode.IsZero() {
109116
return nil, fmt.Errorf("contract %q: zksync_bytecode cannot be set when omit_deploy is true", cfg.Name)
110117
}
118+
if cfg.DeployContractTypes != nil {
119+
if cfg.OmitDeploy {
120+
return nil, fmt.Errorf("contract %q: deploy_contract_types cannot be set when omit_deploy is true", cfg.Name)
121+
}
122+
if len(cfg.DeployContractTypes) == 0 {
123+
return nil, fmt.Errorf("contract %q: deploy_contract_types must contain at least one entry", cfg.Name)
124+
}
125+
if err = validateDeployContractTypes(cfg.Name, cfg.DeployContractTypes); err != nil {
126+
return nil, err
127+
}
128+
}
111129

112130
zkSyncPackage, zkSyncSymbol, err := resolveZkSyncBytecode(cfg, input, cfg.GobindingsPackage)
113131
if err != nil {
@@ -120,14 +138,15 @@ func extractContractInfo(cfg EvmContractConfig, input EvmInputConfig, output Evm
120138
}
121139

122140
info := &ContractInfo{
123-
Name: cfg.Name,
124-
Version: cfg.Version,
125-
PackageName: packageName,
126-
GobindingsPackage: cfg.GobindingsPackage,
127-
OutputPath: core.ContractOutputPath(output.BasePath, versionPath, packageName),
128-
OmitDeploy: cfg.OmitDeploy,
129-
Functions: make(map[string]*FunctionInfo),
130-
StructDefs: make(map[string]*structDef),
141+
Name: cfg.Name,
142+
Version: cfg.Version,
143+
PackageName: packageName,
144+
GobindingsPackage: cfg.GobindingsPackage,
145+
OutputPath: core.ContractOutputPath(output.BasePath, versionPath, packageName),
146+
OmitDeploy: cfg.OmitDeploy,
147+
DeployContractTypes: cfg.DeployContractTypes,
148+
Functions: make(map[string]*FunctionInfo),
149+
StructDefs: make(map[string]*structDef),
131150
}
132151
if zkSyncSymbol != "" {
133152
info.ZkSync = &ZkSyncContractInfo{
@@ -303,6 +322,43 @@ func collectAllStructDefs(info *ContractInfo) {
303322
}
304323
}
305324

325+
// validateDeployContractTypes checks that every label in deploy_contract_types is
326+
// a valid Go exported identifier, unique, and not equal to the base contract name.
327+
func validateDeployContractTypes(contractName string, types []string) error {
328+
seen := make(map[string]struct{}, len(types))
329+
for _, t := range types {
330+
if t == "" {
331+
return fmt.Errorf("contract %q: deploy_contract_types entries must not be empty", contractName)
332+
}
333+
if !isValidGoExportedIdentifier(t) {
334+
return fmt.Errorf("contract %q: deploy_contract_types entry %q must be a valid Go exported identifier", contractName, t)
335+
}
336+
if t == contractName {
337+
return fmt.Errorf("contract %q: deploy_contract_types must not contain the base contract name %q", contractName, t)
338+
}
339+
if _, dup := seen[t]; dup {
340+
return fmt.Errorf("contract %q: duplicate deploy_contract_types entry %q", contractName, t)
341+
}
342+
seen[t] = struct{}{}
343+
}
344+
345+
return nil
346+
}
347+
348+
func isValidGoExportedIdentifier(s string) bool {
349+
first, size := utf8.DecodeRuneInString(s)
350+
if size == 0 || !unicode.IsUpper(first) {
351+
return false
352+
}
353+
for _, r := range s[size:] {
354+
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' {
355+
return false
356+
}
357+
}
358+
359+
return true
360+
}
361+
306362
// Absolute paths and any cleaned path containing ".." or a path separator are rejected.
307363
func validatePathSegment(field, value string) error {
308364
if filepath.IsAbs(value) {

0 commit comments

Comments
 (0)