diff --git a/taskfiles/generate/Taskfile.yml b/taskfiles/generate/Taskfile.yml index 54acbcf8f..7002f59bb 100644 --- a/taskfiles/generate/Taskfile.yml +++ b/taskfiles/generate/Taskfile.yml @@ -9,27 +9,3 @@ tasks: desc: Generate mocks for interfaces cmds: - mockery - - operations: - desc: Generate contract operation wrappers from a config file - summary: | - Generates type-safe Go operation wrappers for EVM (or other chain family) contracts. - - Pass CONFIG variable with an absolute path, or a path relative to the repo root. - Relative paths are resolved against the repo root before being passed to the generator. - - Example: - task generate:operations CONFIG=/path/to/operations_gen_config.yaml - task generate:operations CONFIG=./path/from/repo/root/operations_gen_config.yaml - vars: - CONFIG: '{{.CONFIG}}' - # If CONFIG is absolute use it as-is; otherwise treat it as relative to the repo root. - # This avoids a dependency on `realpath` which is not available on all platforms. - ABS_CONFIG: - sh: | - case "{{.CONFIG}}" in - /*) printf '%s\n' "{{.CONFIG}}" ;; - *) printf '%s\n' "{{.ROOT_DIR}}/{{.CONFIG}}" ;; - esac - cmds: - - cd {{.ROOT_DIR}}/tools/operations-gen && go run . -config {{.ABS_CONFIG}} diff --git a/tools/operations-gen/README.md b/tools/operations-gen/README.md new file mode 100644 index 000000000..a492a3f54 --- /dev/null +++ b/tools/operations-gen/README.md @@ -0,0 +1,161 @@ +# operations-gen + +Generates type-safe Go operation wrappers for smart contracts from their ABIs. + +## Usage + +```bash +go run ./tools/operations-gen -config /path/to/operations_gen_config.yaml +``` + +The `-config` path can be absolute or relative to the current working directory. + +## Project structure + +```text +tools/operations-gen/ + main.go # CLI entrypoint + chain-family dispatch + templates/ + evm/ + operations.tmpl # EVM codegen template + internal/ + core/ + core.go # Shared config + helpers/interfaces + families/ + evm/ + evm.go # EVM handler implementation + evm_test.go # EVM unit tests + evm_golden_test.go # End-to-end golden generation tests + testdata/ + evm/ # ABI/bytecode/config/golden fixtures +``` + +`main.go` intentionally stays thin: it parses top-level config, loads the template for the selected chain family, and dispatches to the family handler. Shared helpers and common config types live in `internal/core`. + +## Configuration + +Create an `operations_gen_config.yaml` alongside your ABI/bytecode directories: + +```yaml +version: "1.0.0" +chain_family: evm # Optional: defaults to "evm" + +input: + abi_base_path: "./abi" # Directory containing versioned ABI json files + bytecode_base_path: "./bytecode" # Directory containing versioned bytecode .bin files + +output: + base_path: "." # Directory where generated operations/ folders are written + +contracts: + - contract_name: FeeQuoter + version: "1.6.0" + package_name: fee_quoter # Optional: override default package name + abi_file: "fee_quoter.json" # Optional: override default ABI filename + omit_deploy: false # Optional: set true to skip Deploy operation generation (default: false) + functions: + - name: updatePrices + access: owner # Write op with MCMS support + - name: getTokenPrice + access: public # Read op (or public write op) +``` + +### Top-level fields + +| Field | Required | Description | +| ------------------ | -------- | --------------------------------------------------------------------------------------------- | +| `version` | Yes | Config schema version | +| `chain_family` | No | Target chain family. Only `"evm"` is supported. Defaults to `"evm"`. | +| `input.abi_base_path` | Yes | Directory containing versioned ABI files. Relative to the config file. | +| `input.bytecode_base_path` | Yes | Directory containing versioned bytecode files. Relative to the config file. | +| `output.base_path` | Yes | Root directory where generated files are written. Relative to the config file. | + +### Contract fields + +| Field | Required | Description | +| --------------- | -------- | ---------------------------------------------------------------------------------------------------------------- | +| `contract_name` | Yes | Contract name as it appears in the ABI (e.g. `FeeQuoter`) | +| `version` | Yes | Semver version of the contract (e.g. `"1.6.0"`) | +| `package_name` | No | Override the generated Go package name. Defaults to `snake_case(contract_name)`. | +| `abi_file` | No | Override the ABI filename. Defaults to `{package_name}.json`. | +| `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`. | + +### Function access control + +| Value | Behaviour | +| -------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `owner` | Generates a write operation gated by `OnlyOwner`, producing an MCMS-compatible transaction when the deployer key is not the owner. | +| `public` | Generates a read operation (for `view`/`pure` functions) or an unrestricted write operation. | + +## Input layout + +The generator expects ABIs and bytecode under separate input roots: + +``` +{input.abi_base_path}/ + v1_6_0/ + fee_quoter.json + +{input.bytecode_base_path}/ + v1_6_0/ + fee_quoter.bin +``` + +Version `1.6.0` maps to directory `v1_6_0`. Override with `version_path` if your layout differs. + +## Output layout + +Generated files are written to: + +``` +{output.base_path}/ + v1_6_0/ + operations/ + fee_quoter/ + fee_quoter.go +``` + +Each generated file contains: + +- ABI and bytecode constants +- A bound contract wrapper with typed methods +- A `Deploy` operation (unless `omit_deploy: true`) +- A typed write operation for each `access: owner` or writable `access: public` function +- A typed read operation for each `view`/`pure` function + +The generated code imports the runtime helpers from: + +``` +github.com/smartcontractkit/chainlink-deployments-framework/chain/evm/operations/contract +``` + +## Extending to new chain families + +> Only `evm` is supported today. The steps below describe how to add support for a new family in the future. + +The generator dispatches entirely by `chain_family`. Each family owns its own YAML contract schema, type mappings, template, and generation logic; only common CLI/config plumbing and dispatch utilities are shared. + +To add a new chain family (e.g. `solana`): + +1. Create `internal/families/solana/solana.go` with a `solana.Handler` type implementing `core.ChainFamilyHandler`: + + ```go + type ChainFamilyHandler interface { + Generate(config core.Config, tmpl *template.Template) error + } + ``` + + The handler receives the full `core.Config`. `Config.Input`, `Config.Output`, and `Config.Contracts` are `yaml.Node` values so each chain-family handler can decode its own chain-specific schemas. + +2. Add `templates/solana/operations.tmpl` with chain-appropriate imports and method bodies. + +3. Register the handler in `chainFamilies` in `main.go`: + ```go + var chainFamilies = map[string]core.ChainFamilyHandler{ + "evm": evm.Handler{}, + "solana": solana.Handler{}, + } + ``` + +No other changes to `main.go` are needed. Set `chain_family: solana` in your config to use it. diff --git a/tools/operations-gen/internal/core/core.go b/tools/operations-gen/internal/core/core.go new file mode 100644 index 000000000..adfebdde3 --- /dev/null +++ b/tools/operations-gen/internal/core/core.go @@ -0,0 +1,63 @@ +package core + +import ( + "fmt" + "go/format" + "os" + "path/filepath" + "strings" + "text/template" + + "gopkg.in/yaml.v3" +) + +// ChainFamilyHandler abstracts all chain-specific generation logic. +type ChainFamilyHandler interface { + Generate(config Config, tmpl *template.Template) error +} + +// Config holds the top-level generator configuration. +// Input/Output/Contracts are raw YAML nodes so handlers own their own schemas. +type Config struct { + Version string `yaml:"version"` + ChainFamily string `yaml:"chain_family"` // defaults to "evm" + Input yaml.Node `yaml:"input"` + Output yaml.Node `yaml:"output"` + Contracts yaml.Node `yaml:"contracts"` + ConfigDir string `yaml:"-"` +} + +// WriteGoFile formats src as Go source and writes it to path, creating parent directories. +func WriteGoFile(path string, src []byte) error { + formatted, err := format.Source(src) + if err != nil { + return fmt.Errorf("formatting error: %w\n%s", err, src) + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("failed to create output directory: %w", err) + } + if err := os.WriteFile(path, formatted, 0o600); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +// VersionToPath converts a semver string to a directory path segment. +func VersionToPath(version string) string { + return "v" + strings.ReplaceAll(version, ".", "_") +} + +// ContractOutputPath builds the output file path for a generated contract operations file. +func ContractOutputPath(basePath, versionPath, packageName string) string { + return filepath.Join(basePath, versionPath, "operations", packageName, packageName+".go") +} + +// Capitalize uppercases the first character of s. +func Capitalize(s string) string { + if s == "" { + return "" + } + + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/tools/operations-gen/internal/core/core_test.go b/tools/operations-gen/internal/core/core_test.go new file mode 100644 index 000000000..daf434da5 --- /dev/null +++ b/tools/operations-gen/internal/core/core_test.go @@ -0,0 +1,75 @@ +package core + +import ( + "os" + "path/filepath" + "testing" +) + +// TestVersionToPath verifies the semver-to-directory-path conversion. +func TestVersionToPath(t *testing.T) { + t.Parallel() + cases := []struct{ version, want string }{ + {"1.0.0", "v1_0_0"}, + {"1.2.3", "v1_2_3"}, + {"0.0.1", "v0_0_1"}, + } + for _, tc := range cases { + t.Run(tc.version, func(t *testing.T) { + t.Parallel() + if got := VersionToPath(tc.version); got != tc.want { + t.Errorf("VersionToPath(%q) = %q, want %q", tc.version, got, tc.want) + } + }) + } +} + +func TestCapitalize(t *testing.T) { + t.Parallel() + cases := []struct { + input, want string + }{ + {"", ""}, + {"hello", "Hello"}, + {"Hello", "Hello"}, + {"hELLO", "HELLO"}, + {"a", "A"}, + {"1abc", "1abc"}, // non-alpha first char is left as-is + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + if got := Capitalize(tc.input); got != tc.want { + t.Errorf("Capitalize(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestWriteGoFileInvalidSource(t *testing.T) { + t.Parallel() + dir := t.TempDir() + err := WriteGoFile(filepath.Join(dir, "out.go"), []byte("this is not valid Go }{")) + if err == nil { + t.Fatal("expected formatting error for invalid Go source, got nil") + } +} + +func TestWriteGoFileWritesFormattedFile(t *testing.T) { + t.Parallel() + dir := t.TempDir() + src := []byte("package foo\nfunc F(){}\n") + outPath := filepath.Join(dir, "sub", "out.go") + + if err := WriteGoFile(outPath, src); err != nil { + t.Fatalf("WriteGoFile: %v", err) + } + + got, err := os.ReadFile(outPath) + if err != nil { + t.Fatalf("reading output: %v", err) + } + if len(got) == 0 { + t.Error("expected non-empty output file") + } +} diff --git a/tools/operations-gen/internal/families/evm/evm.go b/tools/operations-gen/internal/families/evm/evm.go new file mode 100644 index 000000000..9aaf826a6 --- /dev/null +++ b/tools/operations-gen/internal/families/evm/evm.go @@ -0,0 +1,910 @@ +package evm + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "text/template" + + "github.com/smartcontractkit/chainlink-deployments-framework/tools/operations-gen/internal/core" +) + +const ( + // anyType is the fallback Go type for unknown source types. + anyType = "any" + // emptyReturnType is the Go type used for read functions with no return values. + emptyReturnType = "struct{}" +) + +// evmTypeMap maps Solidity types to their Go equivalents. +var evmTypeMap = map[string]string{ + "address": "common.Address", + "string": "string", + "bool": "bool", + "bytes": "[]byte", + "bytes32": "[32]byte", + "bytes16": "[16]byte", + "bytes4": "[4]byte", + "uint8": "uint8", + "uint16": "uint16", + "uint32": "uint32", + "uint64": "uint64", + "uint96": "*big.Int", + "uint128": "*big.Int", + "uint160": "*big.Int", + "uint192": "*big.Int", + "uint224": "*big.Int", + "uint256": "*big.Int", + "int8": "int8", + "int16": "int16", + "int32": "int32", + "int64": "int64", + "int96": "*big.Int", + "int128": "*big.Int", + "int160": "*big.Int", + "int192": "*big.Int", + "int224": "*big.Int", + "int256": "*big.Int", +} + +// ---- EVM contract config (YAML schema owned by Handler) ---- + +// 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 + ABIFile string `yaml:"abi_file,omitempty"` // Optional: override ABI file name + OmitDeploy bool `yaml:"omit_deploy,omitempty"` // Optional: skip Deploy operation + Functions []evmFunctionConfig `yaml:"functions"` +} + +// evmFunctionConfig selects a contract function and assigns its access control. +type evmFunctionConfig struct { + Name string `yaml:"name"` + Access string `yaml:"access,omitempty"` // "owner" or "public" +} + +type evmInputConfig struct { + ABIBasePath string `yaml:"abi_base_path"` + BytecodeBasePath string `yaml:"bytecode_base_path"` +} + +type evmOutputConfig struct { + BasePath string `yaml:"base_path"` +} + +// ---- Intermediate representation ---- + +// contractInfo holds all parsed information about a contract needed for code generation. +type contractInfo struct { + Name string + Version string + PackageName string + OutputPath string + ABI string + Bytecode string + OmitDeploy bool + Constructor *functionInfo + Functions map[string]*functionInfo + FunctionOrder []string + StructDefs map[string]*structDef +} + +type structDef struct { + Name string + Fields []parameterInfo +} + +type functionInfo struct { + Name string + StateMutability string + Parameters []parameterInfo + ReturnParams []parameterInfo + IsWrite bool + CallMethod string // Method name, with numeric suffix for overloaded functions + HasOnlyOwner bool +} + +type parameterInfo struct { + Name string + SolidityType string + GoType string + IsStruct bool + StructName string + Components []parameterInfo +} + +// ---- Template data (EVM-specific) ---- + +type templateData struct { + PackageName string + PackageNameHyphen string + ContractType string + Version string + ABI string + Bytecode string + NeedsBigInt bool + HasWriteOps bool + OmitDeploy bool + Constructor *constructorData + StructDefs []structDefData + ArgStructs []argStructData + Operations []operationData + ContractMethods []contractMethodData +} + +type constructorData struct { + Parameters []parameterData +} + +type structDefData struct { + Name string + Fields []parameterData +} + +type argStructData struct { + Name string + Fields []parameterData +} + +type parameterData struct { + GoName string + GoType string + JSONTag string // ABI parameter name; may be a synthesized placeholder (e.g. "ret0") for unnamed outputs +} + +type operationData struct { + Name string + MethodName string + OpName string + ArgsType string + CallArgs string + IsWrite bool + AccessControl string // Only for writes + ReturnType string // Only for reads +} + +type contractMethodData struct { + Name string + MethodName string + Params string + Returns string + MethodBody string +} + +// ---- Handler ---- + +// Handler implements ChainFamilyHandler for EVM (Solidity/go-ethereum) chains. +type Handler struct{} + +// Generate decodes each YAML node as an evmContractConfig, extracts contract info, +// and writes a generated operations file for each contract. +func (h Handler) Generate(config core.Config, tmpl *template.Template) error { + var input evmInputConfig + if err := config.Input.Decode(&input); err != nil { + return fmt.Errorf("failed to decode EVM input config: %w", err) + } + var output evmOutputConfig + if err := config.Output.Decode(&output); err != nil { + return fmt.Errorf("failed to decode EVM output config: %w", err) + } + if config.ConfigDir != "" { + input.ABIBasePath = filepath.Join(config.ConfigDir, input.ABIBasePath) + input.BytecodeBasePath = filepath.Join(config.ConfigDir, input.BytecodeBasePath) + output.BasePath = filepath.Join(config.ConfigDir, output.BasePath) + } + + for _, node := range config.Contracts.Content { + if node == nil { + continue + } + var cfg evmContractConfig + if err := node.Decode(&cfg); err != nil { + return fmt.Errorf("failed to decode EVM contract config: %w", err) + } + + info, err := extractContractInfo(cfg, input, output) + if err != nil { + return fmt.Errorf("error extracting info for %s: %w", cfg.Name, err) + } + + if err := generateOperationsFile(info, tmpl); err != nil { + return fmt.Errorf("error generating file for %s: %w", cfg.Name, err) + } + + fmt.Printf("✓ Generated operations for %s at %s\n", info.Name, info.OutputPath) + } + + return nil +} + +// ---- Extraction ---- + +func extractContractInfo(cfg evmContractConfig, input evmInputConfig, output evmOutputConfig) (*contractInfo, error) { + if cfg.Name == "" || cfg.Version == "" { + return nil, errors.New("contract_name and version are required") + } + + packageName := cfg.PackageName + if packageName == "" { + packageName = toSnakeCase(cfg.Name) + } + versionPath := core.VersionToPath(cfg.Version) + if cfg.VersionPath != "" { + versionPath = cfg.VersionPath + } + + if err := validatePathSegment("package_name", packageName); err != nil { + return nil, err + } + if err := validatePathSegment("version_path", versionPath); err != nil { + return nil, err + } + + abiString, bytecode, err := readABIAndBytecode(cfg, packageName, versionPath, input) + if err != nil { + return nil, err + } + + abiEntries, err := parseABIEntries(abiString) + if err != nil { + return nil, err + } + + info := &contractInfo{ + Name: cfg.Name, + Version: cfg.Version, + PackageName: packageName, + OutputPath: core.ContractOutputPath(output.BasePath, versionPath, packageName), + ABI: abiString, + Bytecode: bytecode, + OmitDeploy: cfg.OmitDeploy, + Functions: make(map[string]*functionInfo), + StructDefs: make(map[string]*structDef), + } + + extractConstructor(info, abiEntries, evmTypeMap) + + if err := extractFunctions(info, cfg.Functions, abiEntries, evmTypeMap); err != nil { + return nil, err + } + + collectAllStructDefs(info) + + return info, nil +} + +func collectAllStructDefs(info *contractInfo) { + if info.Constructor != nil { + collectStructDefs(info.Constructor.Parameters, info.StructDefs) + } + for _, fi := range info.Functions { + collectStructDefs(fi.Parameters, info.StructDefs) + collectStructDefs(fi.ReturnParams, info.StructDefs) + + if !fi.IsWrite && len(fi.ReturnParams) > 1 { + structName := multiReturnStructName(fi.Name) + if _, exists := info.StructDefs[structName]; !exists { + info.StructDefs[structName] = &structDef{ + Name: structName, + Fields: fi.ReturnParams, + } + } + } + } +} + +func collectStructDefs(params []parameterInfo, structDefs map[string]*structDef) { + for _, param := range params { + if param.IsStruct && param.StructName != "" { + if _, exists := structDefs[param.StructName]; !exists { + structDefs[param.StructName] = &structDef{ + Name: param.StructName, + Fields: param.Components, + } + } + collectStructDefs(param.Components, structDefs) + } + } +} + +// ---- Code generation ---- + +func generateOperationsFile(info *contractInfo, tmpl *template.Template) error { + data := prepareTemplateData(info) + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return fmt.Errorf("template execution error: %w", err) + } + + return core.WriteGoFile(info.OutputPath, buf.Bytes()) +} + +func prepareTemplateData(info *contractInfo) templateData { + data := templateData{ + PackageName: info.PackageName, + PackageNameHyphen: toKebabCase(info.PackageName), + ContractType: info.Name, + Version: info.Version, + ABI: info.ABI, + Bytecode: info.Bytecode, + NeedsBigInt: checkNeedsBigInt(info), + OmitDeploy: info.OmitDeploy, + } + + if info.Constructor != nil { + data.Constructor = &constructorData{ + Parameters: prepareParameters(info.Constructor.Parameters), + } + } + + for _, name := range info.FunctionOrder { + fi := info.Functions[name] + data.ContractMethods = append(data.ContractMethods, prepareContractMethod(fi, fi.IsWrite)) + + if fi.IsWrite { + data.HasWriteOps = true + data.Operations = append(data.Operations, prepareWriteOp(fi)) + } else { + data.Operations = append(data.Operations, prepareReadOp(fi)) + } + + if len(fi.Parameters) > 1 { + data.ArgStructs = append(data.ArgStructs, argStructData{ + Name: fi.Name + "Args", + Fields: prepareParameters(fi.Parameters), + }) + } + } + + structNames := make([]string, 0, len(info.StructDefs)) + for name := range info.StructDefs { + structNames = append(structNames, name) + } + sort.Strings(structNames) + for _, name := range structNames { + sd := info.StructDefs[name] + data.StructDefs = append(data.StructDefs, structDefData{ + Name: sd.Name, + Fields: prepareParameters(sd.Fields), + }) + } + + return data +} + +func prepareParameters(params []parameterInfo) []parameterData { + result := make([]parameterData, 0, len(params)) + for i, param := range params { + name := sanitizeFieldName(param.Name) + if name == "" { + name = fmt.Sprintf("Field%d", i) + } + result = append(result, parameterData{ + GoName: name, + GoType: param.GoType, + JSONTag: param.Name, + }) + } + + return result +} + +// buildCallArgs builds the argsType and callArgs strings for an operation. +func buildCallArgs(fi *functionInfo) (argsType string, callArgs string) { + if len(fi.Parameters) == 0 { + return emptyReturnType, "" + } + + if len(fi.Parameters) == 1 { + return fi.Parameters[0].GoType, ", args" + } + + argsType = fi.Name + "Args" + var callArgsList []string + for i, p := range fi.Parameters { + fieldName := sanitizeFieldName(p.Name) + if fieldName == "" { + fieldName = fmt.Sprintf("Field%d", i) + } + callArgsList = append(callArgsList, "args."+fieldName) + } + callArgs = ", " + strings.Join(callArgsList, ", ") + + return argsType, callArgs +} + +func resolveReturnType(fi *functionInfo) string { + if len(fi.ReturnParams) == 1 { + return fi.ReturnParams[0].GoType + } else if len(fi.ReturnParams) > 1 { + return multiReturnStructName(fi.Name) + } + + return emptyReturnType +} + +func prepareWriteOp(fi *functionInfo) operationData { + argsType, callArgs := buildCallArgs(fi) + + accessControl := "AllCallersAllowed" + if fi.HasOnlyOwner { + accessControl = "OnlyOwner" + } + + return operationData{ + Name: fi.Name, + MethodName: fi.CallMethod, + OpName: toKebabCase(fi.Name), + ArgsType: argsType, + CallArgs: callArgs, + IsWrite: true, + AccessControl: accessControl, + } +} + +func prepareReadOp(fi *functionInfo) operationData { + argsType, callArgs := buildCallArgs(fi) + + return operationData{ + Name: fi.Name, + MethodName: fi.CallMethod, + OpName: toKebabCase(fi.Name), + ArgsType: argsType, + ReturnType: resolveReturnType(fi), + CallArgs: callArgs, + IsWrite: false, + } +} + +func multiReturnStructName(funcName string) string { + return funcName + "Result" +} + +// prepareContractMethod builds the contractMethodData for a single contract function, +// generating go-ethereum–specific method signatures and bodies. +func prepareContractMethod(fi *functionInfo, isWrite bool) contractMethodData { + optsType := "*bind.CallOpts" + if isWrite { + optsType = "*bind.TransactOpts" + } + + params := "opts " + optsType + var methodArgs []string + + if len(fi.Parameters) == 1 { + params += ", args " + fi.Parameters[0].GoType + methodArgs = []string{"args"} + } else if len(fi.Parameters) > 1 { + var sb strings.Builder + for _, p := range fi.Parameters { + paramName := sanitizeParamName(p.Name) + if paramName == "" { + paramName = fmt.Sprintf("arg%d", len(methodArgs)) + } + fmt.Fprintf(&sb, ", %s %s", paramName, p.GoType) + methodArgs = append(methodArgs, paramName) + } + params += sb.String() + } + + returns := "(*types.Transaction, error)" + if !isWrite { + returns = fmt.Sprintf("(%s, error)", resolveReturnType(fi)) + } + + var methodBody string + if isWrite { + methodBody = buildWriteMethodBody(fi, methodArgs) + } else { + methodBody = buildReadMethodBody(fi, methodArgs, resolveReturnType(fi)) + } + + return contractMethodData{ + Name: fi.Name, + MethodName: fi.CallMethod, + Params: params, + Returns: returns, + MethodBody: methodBody, + } +} + +// buildWriteMethodBody generates the body of a write (transact) method. +func buildWriteMethodBody(fi *functionInfo, methodArgs []string) string { + if len(methodArgs) > 0 { + return fmt.Sprintf("return c.contract.Transact(opts, \"%s\", %s)", + fi.CallMethod, strings.Join(methodArgs, ", ")) + } + + return fmt.Sprintf("return c.contract.Transact(opts, \"%s\")", fi.CallMethod) +} + +// buildReadMethodBody generates the body of a read (call) method. +func buildReadMethodBody(fi *functionInfo, methodArgs []string, returnType string) string { + callArgsStr := "" + if len(methodArgs) > 0 { + callArgsStr = ", " + strings.Join(methodArgs, ", ") + } + if len(fi.ReturnParams) == 0 { + return fmt.Sprintf( + `err := c.contract.Call(opts, nil, "%s"%s) + return struct{}{}, err`, + fi.CallMethod, callArgsStr, + ) + } + if len(fi.ReturnParams) > 1 { + return buildMultiReturnMethodBody(fi, callArgsStr, returnType) + } + + return fmt.Sprintf( + `var out []any + err := c.contract.Call(opts, &out, "%s"%s) + if err != nil { + var zero %s + return zero, err + } + return *abi.ConvertType(out[0], new(%s)).(*%s), nil`, + fi.CallMethod, callArgsStr, returnType, returnType, returnType, + ) +} + +// buildMultiReturnMethodBody generates the body for a read method with multiple return values, +// packing them into a result struct. +func buildMultiReturnMethodBody(fi *functionInfo, callArgsStr, returnType string) string { + var b strings.Builder + fmt.Fprintf(&b, "var out []any\n") + fmt.Fprintf(&b, "\terr := c.contract.Call(opts, &out, \"%s\"%s)\n", fi.CallMethod, callArgsStr) + fmt.Fprintf(&b, "\toutstruct := new(%s)\n", returnType) + fmt.Fprintf(&b, "\tif err != nil {\n") + fmt.Fprintf(&b, "\t\treturn *outstruct, err\n") + fmt.Fprintf(&b, "\t}\n\n") + for i, p := range fi.ReturnParams { + fieldName := sanitizeFieldName(p.Name) + if fieldName == "" { + fieldName = fmt.Sprintf("Field%d", i) + } + fmt.Fprintf(&b, "\toutstruct.%s = *abi.ConvertType(out[%d], new(%s)).(*%s)\n", + fieldName, i, p.GoType, p.GoType) + } + fmt.Fprintf(&b, "\n\treturn *outstruct, nil") + + return b.String() +} + +// ---- ABI parsing ---- + +// ABIEntry represents a single entry in a Solidity contract ABI JSON. +type ABIEntry struct { + Type string `json:"type"` + Name string `json:"name"` + Inputs []ABIParam `json:"inputs"` + Outputs []ABIParam `json:"outputs"` + StateMutability string `json:"stateMutability"` +} + +// ABIParam represents a parameter within an ABI entry. +type ABIParam struct { + Name string `json:"name"` + Type string `json:"type"` + InternalType string `json:"internalType"` + Components []ABIParam `json:"components"` +} + +// readABIAndBytecode reads the ABI JSON and (optionally) bytecode for a contract +// from the configured input roots: +// +// {input.ABIBasePath}/{versionPath}/{name}.json +// {input.BytecodeBasePath}/{versionPath}/{name}.bin +func readABIAndBytecode( + cfg evmContractConfig, + packageName, + versionPath string, + input evmInputConfig) (abiString string, bytecode string, err error) { + var abiFileName string + if cfg.ABIFile != "" { + if !strings.HasSuffix(cfg.ABIFile, ".json") { + return "", "", fmt.Errorf("abi_file %q must end with .json", cfg.ABIFile) + } + abiFileName = cfg.ABIFile + } else { + abiFileName = packageName + ".json" + } + + abiPath := filepath.Join(input.ABIBasePath, versionPath, abiFileName) + abiBytes, err := os.ReadFile(abiPath) + if err != nil { + return "", "", fmt.Errorf("failed to read ABI from %s: %w", abiPath, err) + } + + if cfg.OmitDeploy { + return string(abiBytes), "", nil + } + + bytecodeName := strings.TrimSuffix(abiFileName, ".json") + ".bin" + bytecodePath := filepath.Join(input.BytecodeBasePath, versionPath, bytecodeName) + bytecodeBytes, err := os.ReadFile(bytecodePath) + if err != nil { + return "", "", fmt.Errorf("failed to read bytecode from %s: %w", bytecodePath, err) + } + + return string(abiBytes), strings.TrimSpace(string(bytecodeBytes)), nil +} + +func extractConstructor(info *contractInfo, abiEntries []ABIEntry, typeMap map[string]string) { + for _, entry := range abiEntries { + if entry.Type == "constructor" { + info.Constructor = parseABIFunction(entry, info.PackageName, typeMap) + break + } + } +} + +func extractFunctions(info *contractInfo, funcConfigs []evmFunctionConfig, abiEntries []ABIEntry, typeMap map[string]string) error { + for _, funcCfg := range funcConfigs { + funcInfos := findFunctionInABI(abiEntries, funcCfg.Name, info.PackageName, typeMap) + if funcInfos == nil { + return fmt.Errorf("function %s not found in ABI", funcCfg.Name) + } + + for _, fi := range funcInfos { + switch funcCfg.Access { + case "owner": + fi.HasOnlyOwner = true + case "public", "": + fi.HasOnlyOwner = false + default: + return fmt.Errorf("unknown access control '%s' for function %s (use 'owner' or 'public')", + funcCfg.Access, funcCfg.Name) + } + + info.Functions[fi.Name] = fi + info.FunctionOrder = append(info.FunctionOrder, fi.Name) + } + } + + return nil +} + +// findFunctionInABI finds all overloads of a function by name and returns functionInfo +// for each, following Geth's overload naming convention. +func findFunctionInABI(entries []ABIEntry, funcName string, packageName string, typeMap map[string]string) []*functionInfo { + var candidates []ABIEntry + for _, entry := range entries { + if entry.Type == "function" && strings.EqualFold(entry.Name, funcName) { + candidates = append(candidates, entry) + } + } + + if len(candidates) == 0 { + return nil + } + + var funcInfos []*functionInfo + for i, candidate := range candidates { + fi := parseABIFunction(candidate, packageName, typeMap) + + // Follow Geth's overload naming convention: + // First: no suffix, second: "0", third: "1", etc. + if len(candidates) > 1 && i > 0 { + suffix := strconv.Itoa(i - 1) + fi.Name = fi.Name + suffix + fi.CallMethod = fi.CallMethod + suffix + } + + funcInfos = append(funcInfos, fi) + } + + return funcInfos +} + +// parseABIFunction converts a Solidity ABI function entry into a functionInfo. +// IsWrite is determined by stateMutability: anything other than "view" or "pure" is a write. +func parseABIFunction(entry ABIEntry, packageName string, typeMap map[string]string) *functionInfo { + fi := &functionInfo{ + Name: core.Capitalize(entry.Name), + StateMutability: entry.StateMutability, + CallMethod: entry.Name, + IsWrite: entry.StateMutability != "view" && entry.StateMutability != "pure", + } + + for i, input := range entry.Inputs { + p := parseABIParam(input, packageName, typeMap) + if p.Name == "" { + p.Name = fmt.Sprintf("arg%d", i) + } + fi.Parameters = append(fi.Parameters, p) + } + + for i, output := range entry.Outputs { + p := parseABIParam(output, packageName, typeMap) + if p.Name == "" { + p.Name = fmt.Sprintf("ret%d", i) + } + fi.ReturnParams = append(fi.ReturnParams, p) + } + + return fi +} + +//nolint:unparam +func parseABIParam(param ABIParam, packageName string, typeMap map[string]string) parameterInfo { + goType := solidityToGoType(param.Type, typeMap) + + pi := parameterInfo{ + Name: param.Name, + SolidityType: param.Type, + GoType: goType, + } + + if strings.HasPrefix(param.Type, "tuple") { + structName := extractStructName(param.InternalType) + if structName != "" { + pi.IsStruct = true + pi.StructName = structName + + if strings.HasSuffix(param.Type, "[]") { + pi.GoType = "[]" + structName + } else { + pi.GoType = structName + } + + for _, comp := range param.Components { + pi.Components = append(pi.Components, parseABIParam(comp, packageName, typeMap)) + } + } + } + + return pi +} + +// solidityToGoType maps a Solidity type string to its Go equivalent using typeMap. +func solidityToGoType(solidityType string, typeMap map[string]string) string { + baseType := strings.TrimSuffix(solidityType, "[]") + if goType, ok := typeMap[baseType]; ok { + if strings.HasSuffix(solidityType, "[]") { + return "[]" + goType + } + + return goType + } + + if strings.HasPrefix(baseType, "tuple") { + return anyType + } + + return anyType +} + +// extractStructName parses the Go struct name from a Solidity ABI internalType field. +// e.g. "struct IOnRamp.DestChainConfig" → "DestChainConfig" +// e.g. "struct MyStruct" → "MyStruct" (no module prefix) +// Returns "" for anonymous tuples ("tuple", "tuple[]") so callers fall back to any. +func extractStructName(internalType string) string { + if internalType == "" { + return "" + } + + // Bare "tuple" / "tuple[]" have no named struct — callers should fall back to any. + if strings.HasPrefix(internalType, "tuple") { + return "" + } + + normalized := strings.TrimPrefix(internalType, "struct ") + normalized = strings.TrimSuffix(normalized, "[]") + parts := strings.Split(normalized, ".") + + return parts[len(parts)-1] +} + +// parseABIEntries unmarshals a raw ABI JSON string into a slice of ABIEntry. +func parseABIEntries(abiString string) ([]ABIEntry, error) { + var entries []ABIEntry + if err := json.Unmarshal([]byte(abiString), &entries); err != nil { + return nil, fmt.Errorf("failed to parse ABI: %w", err) + } + + return entries, nil +} + +// checkNeedsBigInt reports whether any parameter in the contract uses *big.Int, +// which requires importing "math/big" in the generated file. +func checkNeedsBigInt(info *contractInfo) bool { + var check func(params []parameterInfo) bool + check = func(params []parameterInfo) bool { + for _, p := range params { + if strings.Contains(p.GoType, "*big.Int") { + return true + } + if len(p.Components) > 0 && check(p.Components) { + return true + } + } + + return false + } + + for _, fi := range info.Functions { + if check(fi.Parameters) || check(fi.ReturnParams) { + return true + } + } + + if info.Constructor != nil && check(info.Constructor.Parameters) { + return true + } + + return false +} + +// ---- Naming utilities ---- + +// sanitizeFieldName strips leading underscores and capitalizes the result, +// producing a valid exported Go identifier for struct fields. +// Returns "" when the result would start with a digit (e.g. "_1" → ""); callers fall back to "Field%d". +// e.g. "_to" → "To", "_value" → "Value", "balance" → "Balance" +func sanitizeFieldName(name string) string { + trimmed := strings.TrimLeft(name, "_") + if len(trimmed) == 0 || (trimmed[0] >= '0' && trimmed[0] <= '9') { + return "" + } + + return core.Capitalize(trimmed) +} + +// sanitizeParamName strips leading underscores and lowercases the first rune, +// producing a valid unexported Go identifier for method parameters. +// Returns "" when the result would start with a digit (e.g. "_1" → ""); callers fall back to "arg%d". +// e.g. "_to" → "to", "_value" → "value" +func sanitizeParamName(name string) string { + name = strings.TrimLeft(name, "_") + if len(name) == 0 || (name[0] >= '0' && name[0] <= '9') { + return "" + } + + return strings.ToLower(name[:1]) + name[1:] +} + +func toSnakeCase(s string) string { + var result []rune + runes := []rune(s) + for i := range runes { + r := runes[i] + if i > 0 && r >= 'A' && r <= 'Z' { + prevLower := runes[i-1] >= 'a' && runes[i-1] <= 'z' + nextLower := i+1 < len(runes) && runes[i+1] >= 'a' && runes[i+1] <= 'z' + if prevLower || nextLower { + result = append(result, '_') + } + } + result = append(result, r) + } + + return strings.ToLower(string(result)) +} + +func toKebabCase(s string) string { + return strings.ReplaceAll(toSnakeCase(s), "_", "-") +} + +// validatePathSegment rejects values that could traverse outside the output base path. +// Absolute paths and any cleaned path containing ".." or a path separator are rejected. +func validatePathSegment(field, value string) error { + if filepath.IsAbs(value) { + return fmt.Errorf("%s must not be an absolute path: %q", field, value) + } + cleaned := filepath.Clean(value) + if strings.Contains(cleaned, "..") || strings.ContainsRune(cleaned, filepath.Separator) { + return fmt.Errorf("%s must not contain path separators or '..': %q", field, value) + } + + return nil +} diff --git a/tools/operations-gen/internal/families/evm/evm_test.go b/tools/operations-gen/internal/families/evm/evm_test.go new file mode 100644 index 000000000..b539323d7 --- /dev/null +++ b/tools/operations-gen/internal/families/evm/evm_test.go @@ -0,0 +1,278 @@ +package evm + +import ( + "testing" +) + +// TestToSnakeCase covers the algorithmic EVM name-normalisation helper +// across representative contract names and mixed casing patterns. +func TestToSnakeCase(t *testing.T) { + t.Parallel() + cases := []struct{ input, want string }{ + {"OnRamp", "on_ramp"}, + {"OffRamp", "off_ramp"}, + {"LinkToken", "link_token"}, + {"FeeQuoter", "fee_quoter"}, + {"EVM2EVMOnRamp", "evm2evm_on_ramp"}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + if got := toSnakeCase(tc.input); got != tc.want { + t.Errorf("toSnakeCase(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestSolidityToGoType(t *testing.T) { + t.Parallel() + cases := []struct { + solidity string + want string + }{ + {"uint256", "*big.Int"}, + {"address", "common.Address"}, + {"bool", "bool"}, + {"string", "string"}, + {"bytes32", "[32]byte"}, + // arrays + {"uint256[]", "[]*big.Int"}, + {"address[]", "[]common.Address"}, + // unknown scalar → any + {"uint512", "any"}, + // tuple → any + {"tuple", "any"}, + {"tuple[]", "any"}, + } + for _, tc := range cases { + t.Run(tc.solidity, func(t *testing.T) { + t.Parallel() + if got := solidityToGoType(tc.solidity, evmTypeMap); got != tc.want { + t.Errorf("solidityToGoType(%q) = %q, want %q", tc.solidity, got, tc.want) + } + }) + } +} + +func TestExtractStructName(t *testing.T) { + t.Parallel() + cases := []struct { + internalType string + want string + }{ + {"struct IOnRamp.DestChainConfig", "DestChainConfig"}, + {"struct IOnRamp.DestChainConfig[]", "DestChainConfig"}, + {"struct MyContract.Foo", "Foo"}, + // no dot — whole string minus [] suffix + {"DestChainConfig", "DestChainConfig"}, + // "struct " prefix without a module qualifier + {"struct MyStruct", "MyStruct"}, + {"struct MyStruct[]", "MyStruct"}, + // anonymous tuples — no struct name, caller falls back to any + {"tuple", ""}, + {"tuple[]", ""}, + // empty + {"", ""}, + } + for _, tc := range cases { + t.Run(tc.internalType, func(t *testing.T) { + t.Parallel() + if got := extractStructName(tc.internalType); got != tc.want { + t.Errorf("extractStructName(%q) = %q, want %q", tc.internalType, got, tc.want) + } + }) + } +} + +func TestSanitizeFieldName(t *testing.T) { + t.Parallel() + cases := []struct{ input, want string }{ + // ABI params with leading underscores (common Solidity convention) + {"_to", "To"}, + {"_value", "Value"}, + {"_spender", "Spender"}, + // Multiple leading underscores + {"__foo", "Foo"}, + // No underscore — plain capitalize + {"balance", "Balance"}, + {"owner", "Owner"}, + // Already exported + {"Amount", "Amount"}, + // Leading underscore followed by digit — result starts with digit, invalid Go identifier + {"_1", ""}, + {"__2foo", ""}, + // Empty + {"", ""}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + if got := sanitizeFieldName(tc.input); got != tc.want { + t.Errorf("sanitizeFieldName(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestSanitizeParamName(t *testing.T) { + t.Parallel() + cases := []struct{ input, want string }{ + {"_to", "to"}, + {"_value", "value"}, + {"_spender", "spender"}, + {"__foo", "foo"}, + // No underscore — lowercase first char + {"Balance", "balance"}, + {"owner", "owner"}, + // Leading underscore followed by digit — result starts with digit, invalid Go identifier + {"_1", ""}, + // Empty + {"", ""}, + } + for _, tc := range cases { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + if got := sanitizeParamName(tc.input); got != tc.want { + t.Errorf("sanitizeParamName(%q) = %q, want %q", tc.input, got, tc.want) + } + }) + } +} + +func TestFindFunctionInABIOverloads(t *testing.T) { + t.Parallel() + entries := []ABIEntry{ + {Type: "function", Name: "transfer", Inputs: []ABIParam{{Name: "to", Type: "address"}, {Name: "amount", Type: "uint256"}}, StateMutability: "nonpayable"}, + {Type: "function", Name: "transfer", Inputs: []ABIParam{{Name: "to", Type: "address"}, {Name: "amount", Type: "uint256"}, {Name: "data", Type: "bytes"}}, StateMutability: "nonpayable"}, + {Type: "function", Name: "transfer", Inputs: []ABIParam{{Name: "to", Type: "address"}, {Name: "amount", Type: "uint256"}, {Name: "data", Type: "bytes"}, {Name: "extra", Type: "bytes32"}}, StateMutability: "nonpayable"}, + } + + results := findFunctionInABI(entries, "transfer", "mypkg", evmTypeMap) + + if len(results) != 3 { + t.Fatalf("expected 3 overloads, got %d", len(results)) + } + // First overload: no suffix + if results[0].Name != "Transfer" || results[0].CallMethod != "transfer" { + t.Errorf("overload[0]: got Name=%q CallMethod=%q", results[0].Name, results[0].CallMethod) + } + // Second overload: suffix "0" + if results[1].Name != "Transfer0" || results[1].CallMethod != "transfer0" { + t.Errorf("overload[1]: got Name=%q CallMethod=%q", results[1].Name, results[1].CallMethod) + } + // Third overload: suffix "1" + if results[2].Name != "Transfer1" || results[2].CallMethod != "transfer1" { + t.Errorf("overload[2]: got Name=%q CallMethod=%q", results[2].Name, results[2].CallMethod) + } +} + +func TestReadABIAndBytecodeInvalidABIFileSuffix(t *testing.T) { + t.Parallel() + cfg := evmContractConfig{ABIFile: "contract.abi"} + _, _, err := readABIAndBytecode(cfg, "contract", "v1_0_0", evmInputConfig{ + ABIBasePath: t.TempDir(), + BytecodeBasePath: t.TempDir(), + }) + if err == nil { + t.Fatal("expected error for abi_file without .json suffix, got nil") + } +} + +func TestFindFunctionInABINotFound(t *testing.T) { + t.Parallel() + entries := []ABIEntry{ + {Type: "function", Name: "transfer"}, + } + if got := findFunctionInABI(entries, "mint", "pkg", evmTypeMap); got != nil { + t.Errorf("expected nil for missing function, got %v", got) + } +} + +func TestCheckNeedsBigInt(t *testing.T) { + t.Parallel() + makeFuncInfo := func(goType string) *functionInfo { + return &functionInfo{ + Name: "Foo", + Parameters: []parameterInfo{{GoType: goType}}, + } + } + + t.Run("parameter needs big.Int", func(t *testing.T) { + t.Parallel() + info := &contractInfo{ + Functions: map[string]*functionInfo{"Foo": makeFuncInfo("*big.Int")}, + FunctionOrder: []string{"Foo"}, + } + if !checkNeedsBigInt(info) { + t.Error("expected true") + } + }) + + t.Run("return param needs big.Int", func(t *testing.T) { + t.Parallel() + info := &contractInfo{ + Functions: map[string]*functionInfo{ + "Foo": {Name: "Foo", ReturnParams: []parameterInfo{{GoType: "*big.Int"}}}, + }, + FunctionOrder: []string{"Foo"}, + } + if !checkNeedsBigInt(info) { + t.Error("expected true") + } + }) + + t.Run("constructor param needs big.Int", func(t *testing.T) { + t.Parallel() + entry := ABIEntry{ + Type: "constructor", + Inputs: []ABIParam{{Name: "supply", Type: "uint256"}}, + } + fi := parseABIFunction(entry, "pkg", evmTypeMap) + info := &contractInfo{ + Constructor: fi, + Functions: map[string]*functionInfo{}, + FunctionOrder: []string{}, + } + if !checkNeedsBigInt(info) { + t.Error("expected true for constructor uint256 param") + } + }) + + t.Run("no big.Int", func(t *testing.T) { + t.Parallel() + info := &contractInfo{ + Functions: map[string]*functionInfo{"Foo": makeFuncInfo("common.Address")}, + FunctionOrder: []string{"Foo"}, + } + if checkNeedsBigInt(info) { + t.Error("expected false") + } + }) + + t.Run("nested tuple component needs big.Int", func(t *testing.T) { + t.Parallel() + info := &contractInfo{ + Functions: map[string]*functionInfo{ + "Foo": { + Name: "Foo", + Parameters: []parameterInfo{ + { + Name: "metadata", + GoType: "RootMetadata", + IsStruct: true, + StructName: "RootMetadata", + Components: []parameterInfo{ + {Name: "chainId", GoType: "*big.Int"}, + }, + }, + }, + }, + }, + FunctionOrder: []string{"Foo"}, + } + if !checkNeedsBigInt(info) { + t.Error("expected true for nested tuple component using *big.Int") + } + }) +} diff --git a/tools/operations-gen/main.go b/tools/operations-gen/main.go index 472e831b7..35ce0e7d1 100644 --- a/tools/operations-gen/main.go +++ b/tools/operations-gen/main.go @@ -4,7 +4,6 @@ import ( "embed" "flag" "fmt" - "go/format" "os" "path/filepath" "sort" @@ -12,48 +11,17 @@ import ( "text/template" "gopkg.in/yaml.v3" + + "github.com/smartcontractkit/chainlink-deployments-framework/tools/operations-gen/internal/core" + "github.com/smartcontractkit/chainlink-deployments-framework/tools/operations-gen/internal/families/evm" ) //go:embed templates var templatesFS embed.FS -// ChainFamilyHandler abstracts all chain-specific generation logic. -// Each implementation owns its own contract config schema, type mappings, -// template data preparation, and method body generation. -// -// To add a new chain family: -// 1. Implement this interface in a new .go file. -// 2. Add a template under templates//operations.tmpl. -// 3. Register the handler in chainFamilies below. -type ChainFamilyHandler interface { - // Generate parses the raw YAML contract nodes and writes an operations - // file for each contract using the provided template. - // The node format is chain-family-specific; each handler decodes its own schema. - Generate(config Config, tmpl *template.Template) error -} - // chainFamilies is the single registration point for all supported chain families. -var chainFamilies = map[string]ChainFamilyHandler{ - // "evm": evmHandler{}, // TODO: enable in next PR -} - -// Config holds the top-level generator configuration. -// Contracts is kept as raw YAML nodes so each handler can decode -// its own chain-specific contract schema. -type Config struct { - Version string `yaml:"version"` - ChainFamily string `yaml:"chain_family"` // defaults to "evm" - Input InputConfig `yaml:"input"` - Output OutputConfig `yaml:"output"` - Contracts yaml.Node `yaml:"contracts"` -} - -type InputConfig struct { - BasePath string `yaml:"base_path"` -} - -type OutputConfig struct { - BasePath string `yaml:"base_path"` +var chainFamilies = map[string]core.ChainFamilyHandler{ + "evm": evm.Handler{}, } func main() { @@ -66,7 +34,7 @@ func main() { os.Exit(1) } - var config Config + var config core.Config if err = yaml.Unmarshal(configData, &config); err != nil { fmt.Fprintf(os.Stderr, "Error parsing config: %v\n", err) os.Exit(1) @@ -97,8 +65,7 @@ func main() { os.Exit(1) } - config.Input.BasePath = filepath.Join(absConfigDir, config.Input.BasePath) - config.Output.BasePath = filepath.Join(absConfigDir, config.Output.BasePath) + config.ConfigDir = absConfigDir if err := handler.Generate(config, tmpl); err != nil { fmt.Fprintf(os.Stderr, "Error generating operations: %v\n", err) @@ -127,34 +94,3 @@ func supportedFamilies() string { return strings.Join(families, ", ") } - -// writeGoFile formats src as Go source and writes it to path, creating parent directories. -// Shared utility available to all chain-family handlers. -func writeGoFile(path string, src []byte) error { - formatted, err := format.Source(src) - if err != nil { - return fmt.Errorf("formatting error: %w\n%s", err, src) - } - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("failed to create output directory: %w", err) - } - if err := os.WriteFile(path, formatted, 0600); err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - - return nil -} - -// versionToPath converts a semver string to a directory path segment. -// e.g. "1.2.3" → "v1_2_3" -func versionToPath(version string) string { - return "v" + strings.ReplaceAll(version, ".", "_") -} - -func capitalize(s string) string { - if s == "" { - return "" - } - - return strings.ToUpper(s[:1]) + s[1:] -} diff --git a/tools/operations-gen/main_test.go b/tools/operations-gen/main_test.go index f808ada9b..db44593c0 100644 --- a/tools/operations-gen/main_test.go +++ b/tools/operations-gen/main_test.go @@ -1,29 +1,9 @@ package main import ( - "os" - "path/filepath" "testing" ) -// TestVersionToPath verifies the semver→directory-path conversion. -func TestVersionToPath(t *testing.T) { - t.Parallel() - cases := []struct{ version, want string }{ - {"1.0.0", "v1_0_0"}, - {"1.2.3", "v1_2_3"}, - {"0.0.1", "v0_0_1"}, - } - for _, tc := range cases { - t.Run(tc.version, func(t *testing.T) { - t.Parallel() - if got := versionToPath(tc.version); got != tc.want { - t.Errorf("versionToPath(%q) = %q, want %q", tc.version, got, tc.want) - } - }) - } -} - // TestLoadTemplate_UnknownFamily verifies that loadTemplate returns an error // for a chain family that has no registered template. func TestLoadTemplate_UnknownFamily(t *testing.T) { @@ -33,53 +13,3 @@ func TestLoadTemplate_UnknownFamily(t *testing.T) { t.Error("expected error for unsupported chain family, got nil") } } - -func TestCapitalize(t *testing.T) { - t.Parallel() - cases := []struct { - input, want string - }{ - {"", ""}, - {"hello", "Hello"}, - {"Hello", "Hello"}, - {"hELLO", "HELLO"}, - {"a", "A"}, - {"1abc", "1abc"}, // non-alpha first char is left as-is - } - for _, tc := range cases { - t.Run(tc.input, func(t *testing.T) { - t.Parallel() - if got := capitalize(tc.input); got != tc.want { - t.Errorf("capitalize(%q) = %q, want %q", tc.input, got, tc.want) - } - }) - } -} - -func TestWriteGoFile_InvalidSource(t *testing.T) { - t.Parallel() - dir := t.TempDir() - err := writeGoFile(filepath.Join(dir, "out.go"), []byte("this is not valid Go }{")) - if err == nil { - t.Fatal("expected formatting error for invalid Go source, got nil") - } -} - -func TestWriteGoFile_WritesFormattedFile(t *testing.T) { - t.Parallel() - dir := t.TempDir() - src := []byte("package foo\nfunc F(){}\n") - outPath := filepath.Join(dir, "sub", "out.go") - - if err := writeGoFile(outPath, src); err != nil { - t.Fatalf("writeGoFile: %v", err) - } - - got, err := os.ReadFile(outPath) - if err != nil { - t.Fatalf("reading output: %v", err) - } - if len(got) == 0 { - t.Error("expected non-empty output file") - } -} diff --git a/tools/operations-gen/templates/evm/operations.tmpl b/tools/operations-gen/templates/evm/operations.tmpl index 2fa231c88..e23c2beb1 100644 --- a/tools/operations-gen/templates/evm/operations.tmpl +++ b/tools/operations-gen/templates/evm/operations.tmpl @@ -25,7 +25,7 @@ var Version = semver.MustParse("{{.Version}}") var TypeAndVersion = cldf_deployment.NewTypeAndVersion(ContractType, *Version) const {{.ContractType}}ABI = `{{.ABI}}` -{{- if not .NoDeployment}} +{{- if not .OmitDeploy}} const {{.ContractType}}Bin = "{{.Bytecode}}" {{- end}} @@ -74,7 +74,7 @@ func (c *{{$.ContractType}}Contract) {{.Name}}({{.Params}}) {{.Returns}} { type {{.Name}} struct { {{- range .Fields}} - {{.GoName}} {{.GoType}} + {{.GoName}} {{.GoType}}{{if .JSONTag}} `json:"{{.JSONTag}}"`{{end}} {{- end}} } {{- end}} @@ -82,11 +82,11 @@ type {{.Name}} struct { type {{.Name}} struct { {{- range .Fields}} - {{.GoName}} {{.GoType}} + {{.GoName}} {{.GoType}}{{if .JSONTag}} `json:"{{.JSONTag}}"`{{end}} {{- end}} } {{- end}} -{{- if not .NoDeployment}} +{{- if not .OmitDeploy}} {{if .Constructor}} type ConstructorArgs struct { diff --git a/tools/operations-gen/testdata/evm/operations_gen_config.yaml b/tools/operations-gen/testdata/evm/operations_gen_config.yaml index aaa9b87ae..e21e6f294 100644 --- a/tools/operations-gen/testdata/evm/operations_gen_config.yaml +++ b/tools/operations-gen/testdata/evm/operations_gen_config.yaml @@ -2,7 +2,8 @@ version: "1.0.0" chain_family: evm input: - base_path: "." + abi_base_path: "./abi" + bytecode_base_path: "./bytecode" output: base_path: "." @@ -13,7 +14,7 @@ contracts: abi_file: "link_token.json" functions: - name: transfer - access: owner + access: public - name: balanceOf access: public - name: approve