Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/long-times-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"operations-gen": minor
---

feat: add deploy_contract_types for same-ABI multi-label deploy
71 changes: 69 additions & 2 deletions tools/operations-gen/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,17 @@ contracts:
access: owner # Write op with MCMS support
- name: getTokenPrice
access: public # Read op (or public write op)

# Same ABI, multiple datastore labels — one Deploy handles all three.
- contract_name: ManyChainMultiSig
version: "1.0.0"
deploy_contract_types:
- ProposerManyChainMultiSig
- BypasserManyChainMultiSig
- CancellerManyChainMultiSig
functions:
- name: setConfig
access: owner
```

### Top-level fields
Expand All @@ -148,8 +159,9 @@ contracts:
| `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. |
| `package_name` | No | Override the generated Go package name. Defaults to `snake_case(contract_name)`. |
| `version_path` | No | Override the directory path derived from the version. Defaults to `v{major}_{minor}_{patch}`. |
| `omit_deploy` | No | Skip generation of the `Deploy` operation and bytecode constant. Defaults to `false`. Cannot be combined with `zksync_bytecode`. |
| `zksync_bytecode` | No | zkSync VM deploy bytecode symbol, or `{package, symbol}`. Package defaults to `input.zksync_bindings_package`, then the contract's `gobindings_package`. |
| `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`. |
| `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). |
| `zksync_bytecode` | No | zkSync VM deploy bytecode symbol, or `{package, symbol}`. Package defaults to `input.zksync_bindings_package`, then the contract's `gobindings_package`. |

### Function access control

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

## Deploy contract types

Some contracts are deployed multiple times with different semantic roles, each requiring a distinct
`ContractType` label in the datastore (e.g. `ProposerManyChainMultiSig`, `BypasserManyChainMultiSig`,
`CancellerManyChainMultiSig`). Because all three share the same ABI and bytecode, using three
separate YAML contract entries would generate three near-identical files. `deploy_contract_types`
solves this: one entry, one generated package, one `Deploy` var — but the `BytecodeByTypeAndVersion`
map holds a key for every label so the caller can deploy under whichever type it needs.

```yaml
- contract_name: ManyChainMultiSig
version: "1.0.0"
deploy_contract_types:
- ProposerManyChainMultiSig
- BypasserManyChainMultiSig
- CancellerManyChainMultiSig
functions:
- name: setConfig
access: owner
```

This generates:

```go
var ContractType cldf_deployment.ContractType = "ManyChainMultiSig"
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]{
BytecodeByTypeAndVersion: map[string]contract.Bytecode{
ProposerManyChainMultiSigTypeAndVersion.String(): { /* ... */ },
BypasserManyChainMultiSigTypeAndVersion.String(): { /* ... */ },
CancellerManyChainMultiSigTypeAndVersion.String(): { /* ... */ },
},
})
```

The caller selects the role at deploy time by passing the appropriate `TypeAndVersion` to `Deploy`:

```go
many_chain_multi_sig.Deploy.Execute(b, chain, contract.DeployInput[many_chain_multi_sig.ConstructorArgs]{
TypeAndVersion: many_chain_multi_sig.ProposerManyChainMultiSigTypeAndVersion,
})
```

**Rules:**
- Labels must be valid Go exported identifiers, non-empty, unique, and different from `contract_name`.
- The list must contain at least one entry; an empty list is rejected.
- Cannot be combined with `omit_deploy: true`.
- 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).

## Gobindings requirements

The generator expects an abigen-generated package that exports the standard metadata symbol:
Expand Down
17 changes: 17 additions & 0 deletions tools/operations-gen/generate/templates/evm/operations.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,13 @@ import (

var ContractType cldf_deployment.ContractType = "{{.ContractType}}"
var Version = semver.MustParse("{{.Version}}")
{{- if not .DeployContractTypes}}
var TypeAndVersion = cldf_deployment.NewTypeAndVersion(ContractType, *Version)
{{- end}}
{{- range .DeployContractTypes}}
var {{.}}ContractType cldf_deployment.ContractType = "{{.}}"
var {{.}}TypeAndVersion = cldf_deployment.NewTypeAndVersion({{.}}ContractType, *Version)
{{- end}}
Comment thread
Copilot marked this conversation as resolved.
{{range .StructDefs}}
type {{.Name}} struct {
{{- range .Fields}}
Expand Down Expand Up @@ -62,12 +68,23 @@ var Deploy = contract.NewDeploy(contract.DeployParams[ConstructorArgs]{
Description: "Deploys the {{.ContractType}} contract",
ContractMetadata: gobindings.{{.ContractType}}MetaData,
BytecodeByTypeAndVersion: map[string]contract.Bytecode{
{{- if not .DeployContractTypes}}
cldf_deployment.NewTypeAndVersion(ContractType, *Version).String(): {
EVM: common.FromHex(gobindings.{{.ContractType}}MetaData.Bin),
{{- if .ZkSyncBytecodeSymbol}}
ZkSyncVM: {{if .ZkSyncBytecodeUseGobindings}}gobindings{{else}}zkbindings{{end}}.{{.ZkSyncBytecodeSymbol}},
{{- end}}
},
{{- else}}
{{- range .DeployContractTypes}}
{{.}}TypeAndVersion.String(): {
EVM: common.FromHex(gobindings.{{$.ContractType}}MetaData.Bin),
{{- if $.ZkSyncBytecodeSymbol}}
ZkSyncVM: {{if $.ZkSyncBytecodeUseGobindings}}gobindings{{else}}zkbindings{{end}}.{{$.ZkSyncBytecodeSymbol}},
{{- end}}
},
{{- end}}
{{- end}}
},
})
{{- end}}
Expand Down
28 changes: 16 additions & 12 deletions tools/operations-gen/internal/families/evm/codegen.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,14 @@ type templateData struct {
NeedsBigInt bool
HasWriteOps bool
OmitDeploy bool
Constructor *constructorData
StructDefs []structDefData
ArgStructs []argStructData
Operations []OperationData
ContractMethods []contractMethodData
// DeployContractTypes holds additional ContractType labels that share this
// contract's ABI and bytecode.
DeployContractTypes []string
Constructor *constructorData
StructDefs []structDefData
ArgStructs []argStructData
Operations []OperationData
ContractMethods []contractMethodData
}

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

func prepareTemplateData(info *ContractInfo) templateData {
data := templateData{
PackageName: info.PackageName,
PackageNameHyphen: toKebabCase(info.PackageName),
ContractType: info.Name,
Version: info.Version,
GobindingsImport: info.GobindingsPackage,
NeedsBigInt: ChecksNeedsBigInt(info),
OmitDeploy: info.OmitDeploy,
PackageName: info.PackageName,
PackageNameHyphen: toKebabCase(info.PackageName),
ContractType: info.Name,
Version: info.Version,
GobindingsImport: info.GobindingsPackage,
NeedsBigInt: ChecksNeedsBigInt(info),
OmitDeploy: info.OmitDeploy,
DeployContractTypes: info.DeployContractTypes,
}
if info.ZkSync != nil {
data.ZkSyncBytecodeSymbol = info.ZkSync.BytecodeSymbol
Expand Down
24 changes: 15 additions & 9 deletions tools/operations-gen/internal/families/evm/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@ package evm

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

type EvmInputConfig struct {
Expand Down
80 changes: 68 additions & 12 deletions tools/operations-gen/internal/families/evm/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"os"
"path/filepath"
"strings"
"unicode"
"unicode/utf8"

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

// ZkSyncContractInfo holds resolved zkSync VM deploy bytecode for code generation.
Expand Down Expand Up @@ -108,6 +115,17 @@ func extractContractInfo(cfg EvmContractConfig, input EvmInputConfig, output Evm
if cfg.OmitDeploy && !cfg.ZkSyncBytecode.IsZero() {
return nil, fmt.Errorf("contract %q: zksync_bytecode cannot be set when omit_deploy is true", cfg.Name)
}
if cfg.DeployContractTypes != nil {
if cfg.OmitDeploy {
return nil, fmt.Errorf("contract %q: deploy_contract_types cannot be set when omit_deploy is true", cfg.Name)
}
if len(cfg.DeployContractTypes) == 0 {
return nil, fmt.Errorf("contract %q: deploy_contract_types must contain at least one entry", cfg.Name)
}
if err = validateDeployContractTypes(cfg.Name, cfg.DeployContractTypes); err != nil {
return nil, err
}
}

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

info := &ContractInfo{
Name: cfg.Name,
Version: cfg.Version,
PackageName: packageName,
GobindingsPackage: cfg.GobindingsPackage,
OutputPath: core.ContractOutputPath(output.BasePath, versionPath, packageName),
OmitDeploy: cfg.OmitDeploy,
Functions: make(map[string]*FunctionInfo),
StructDefs: make(map[string]*structDef),
Name: cfg.Name,
Version: cfg.Version,
PackageName: packageName,
GobindingsPackage: cfg.GobindingsPackage,
OutputPath: core.ContractOutputPath(output.BasePath, versionPath, packageName),
OmitDeploy: cfg.OmitDeploy,
DeployContractTypes: cfg.DeployContractTypes,
Functions: make(map[string]*FunctionInfo),
StructDefs: make(map[string]*structDef),
}
if zkSyncSymbol != "" {
info.ZkSync = &ZkSyncContractInfo{
Expand Down Expand Up @@ -303,6 +322,43 @@ func collectAllStructDefs(info *ContractInfo) {
}
}

// validateDeployContractTypes checks that every label in deploy_contract_types is
// a valid Go exported identifier, unique, and not equal to the base contract name.
func validateDeployContractTypes(contractName string, types []string) error {
seen := make(map[string]struct{}, len(types))
for _, t := range types {
if t == "" {
return fmt.Errorf("contract %q: deploy_contract_types entries must not be empty", contractName)
}
if !isValidGoExportedIdentifier(t) {
return fmt.Errorf("contract %q: deploy_contract_types entry %q must be a valid Go exported identifier", contractName, t)
}
if t == contractName {
return fmt.Errorf("contract %q: deploy_contract_types must not contain the base contract name %q", contractName, t)
}
if _, dup := seen[t]; dup {
return fmt.Errorf("contract %q: duplicate deploy_contract_types entry %q", contractName, t)
}
seen[t] = struct{}{}
}

return nil
}

func isValidGoExportedIdentifier(s string) bool {
first, size := utf8.DecodeRuneInString(s)
if size == 0 || !unicode.IsUpper(first) {
return false
}
for _, r := range s[size:] {
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' {
return false
}
}

return true
}

// Absolute paths and any cleaned path containing ".." or a path separator are rejected.
func validatePathSegment(field, value string) error {
if filepath.IsAbs(value) {
Expand Down
Loading
Loading